[fenix] Closes https://github.com/mozilla-mobile/fenix/issues/24513: add undo snackbar to history group screen

pull/600/head
mike a 2 years ago committed by mergify[bot]
parent 60ed795b5e
commit caa53eb6e9

@ -17,6 +17,7 @@ import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState
import org.mozilla.fenix.home.recenttabs.RecentTab import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.library.history.PendingDeletionHistory
import org.mozilla.fenix.gleanplumb.Message import org.mozilla.fenix.gleanplumb.Message
import org.mozilla.fenix.gleanplumb.MessagingState import org.mozilla.fenix.gleanplumb.MessagingState
@ -57,6 +58,14 @@ sealed class AppAction : Action {
data class DeselectPocketStoriesCategory(val categoryName: String) : AppAction() data class DeselectPocketStoriesCategory(val categoryName: String) : AppAction()
data class PocketStoriesShown(val storiesShown: List<PocketRecommendedStory>) : AppAction() data class PocketStoriesShown(val storiesShown: List<PocketRecommendedStory>) : AppAction()
data class PocketStoriesChange(val pocketStories: List<PocketRecommendedStory>) : AppAction() data class PocketStoriesChange(val pocketStories: List<PocketRecommendedStory>) : AppAction()
/**
* Adds a set of items marked for removal to the app state, to be hidden in the UI.
*/
data class AddPendingDeletionSet(val historyItems: Set<PendingDeletionHistory>) : AppAction()
/**
* Removes a set of items, previously marked for removal, to be displayed again in the UI.
*/
data class UndoPendingDeletionSet(val historyItems: Set<PendingDeletionHistory>) : AppAction()
data class PocketStoriesCategoriesChange(val storiesCategories: List<PocketRecommendedStoriesCategory>) : data class PocketStoriesCategoriesChange(val storiesCategories: List<PocketRecommendedStoriesCategory>) :
AppAction() AppAction()
data class PocketStoriesCategoriesSelectionsChange( data class PocketStoriesCategoriesSelectionsChange(

@ -18,6 +18,7 @@ import org.mozilla.fenix.home.recentbookmarks.RecentBookmark
import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState import org.mozilla.fenix.home.recentsyncedtabs.RecentSyncedTabState
import org.mozilla.fenix.home.recenttabs.RecentTab import org.mozilla.fenix.home.recenttabs.RecentTab
import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem
import org.mozilla.fenix.library.history.PendingDeletionHistory
import org.mozilla.fenix.gleanplumb.MessagingState import org.mozilla.fenix.gleanplumb.MessagingState
/** /**
@ -39,6 +40,8 @@ import org.mozilla.fenix.gleanplumb.MessagingState
* @property pocketStories The list of currently shown [PocketRecommendedStory]s. * @property pocketStories The list of currently shown [PocketRecommendedStory]s.
* @property pocketStoriesCategories All [PocketRecommendedStory] categories. * @property pocketStoriesCategories All [PocketRecommendedStory] categories.
* @property messaging State related messages. * @property messaging State related messages.
* @property pendingDeletionHistoryItems The set of History items marked for removal in the UI,
* awaiting to be removed once the Undo snackbar hides away.
* Also serves as an in memory cache of all stories mapped by category allowing for quick stories filtering. * Also serves as an in memory cache of all stories mapped by category allowing for quick stories filtering.
*/ */
data class AppState( data class AppState(
@ -57,4 +60,5 @@ data class AppState(
val pocketStoriesCategories: List<PocketRecommendedStoriesCategory> = emptyList(), val pocketStoriesCategories: List<PocketRecommendedStoriesCategory> = emptyList(),
val pocketStoriesCategoriesSelections: List<PocketRecommendedStoriesSelectedCategory> = emptyList(), val pocketStoriesCategoriesSelections: List<PocketRecommendedStoriesSelectedCategory> = emptyList(),
val messaging: MessagingState = MessagingState(), val messaging: MessagingState = MessagingState(),
val pendingDeletionHistoryItems: Set<PendingDeletionHistory> = emptySet(),
) : State ) : State

@ -94,7 +94,7 @@ internal object AppStoreReducer {
) )
is AppAction.DisbandSearchGroupAction -> state.copy( is AppAction.DisbandSearchGroupAction -> state.copy(
recentHistory = state.recentHistory.filterNot { recentHistory = state.recentHistory.filterNot {
it is RecentlyVisitedItem.RecentHistoryGroup && ( it is RecentHistoryGroup && (
it.title.equals(action.searchTerm, true) || it.title.equals(action.searchTerm, true) ||
it.title.equals(state.recentSearchGroup?.searchTerm, true) it.title.equals(state.recentSearchGroup?.searchTerm, true)
) )
@ -173,6 +173,11 @@ internal object AppStoreReducer {
state.copy(pocketStoriesCategories = updatedCategories) state.copy(pocketStoriesCategories = updatedCategories)
} }
is AppAction.AddPendingDeletionSet ->
state.copy(pendingDeletionHistoryItems = state.pendingDeletionHistoryItems + action.historyItems)
is AppAction.UndoPendingDeletionSet ->
state.copy(pendingDeletionHistoryItems = state.pendingDeletionHistoryItems - action.historyItems)
} }
} }

@ -16,13 +16,21 @@ import org.mozilla.fenix.library.history.viewholders.HistoryListItemViewHolder
*/ */
class HistoryAdapter( class HistoryAdapter(
private val historyInteractor: HistoryInteractor, private val historyInteractor: HistoryInteractor,
private val onEmptyStateChanged: (Boolean) -> Unit,
) : PagingDataAdapter<History, HistoryListItemViewHolder>(historyDiffCallback), ) : PagingDataAdapter<History, HistoryListItemViewHolder>(historyDiffCallback),
SelectionHolder<History> { SelectionHolder<History> {
private var mode: HistoryFragmentState.Mode = HistoryFragmentState.Mode.Normal private var mode: HistoryFragmentState.Mode = HistoryFragmentState.Mode.Normal
override val selectedItems get() = mode.selectedItems private var pendingDeletionItems = emptySet<PendingDeletionHistory>()
var pendingDeletionIds = emptySet<Long>()
private val itemsWithHeaders: MutableMap<HistoryItemTimeGroup, Int> = mutableMapOf() private val itemsWithHeaders: MutableMap<HistoryItemTimeGroup, Int> = mutableMapOf()
// A flag to track the empty state of the list. Items are not being deleted immediately,
// but hidden from the UI until the Undo snackbar will execute the delayed operation.
// Whether the adapter has actually zero items or all present items are hidden,
// the screen should be updated into proper empty/not empty state.
private var isEmpty = true
override val selectedItems
get() = mode.selectedItems
override fun getItemViewType(position: Int): Int = HistoryListItemViewHolder.LAYOUT_ID override fun getItemViewType(position: Int): Int = HistoryListItemViewHolder.LAYOUT_ID
@ -38,10 +46,45 @@ class HistoryAdapter(
if (itemCount > 0) notifyItemChanged(0) if (itemCount > 0) notifyItemChanged(0)
} }
@Suppress("ComplexMethod")
override fun onBindViewHolder(holder: HistoryListItemViewHolder, position: Int) { override fun onBindViewHolder(holder: HistoryListItemViewHolder, position: Int) {
val current = getItem(position) ?: return val current = getItem(position) ?: return
val isPendingDeletion = pendingDeletionIds.contains(current.visitedAt) var isPendingDeletion = false
var groupPendingDeletionCount = 0
var timeGroup: HistoryItemTimeGroup? = null var timeGroup: HistoryItemTimeGroup? = null
if (position == 0) {
isEmpty = true
}
if (pendingDeletionItems.isNotEmpty()) {
when (current) {
is History.Regular -> {
isPendingDeletion = pendingDeletionItems.find {
it is PendingDeletionHistory.Item && it.visitedAt == current.visitedAt
} != null
}
is History.Group -> {
isPendingDeletion = pendingDeletionItems.find {
it is PendingDeletionHistory.Group && it.visitedAt == current.visitedAt
} != null
if (!isPendingDeletion) {
groupPendingDeletionCount = current.items.count { historyMetadata ->
pendingDeletionItems.find {
it is PendingDeletionHistory.MetaData &&
it.key == historyMetadata.historyMetadataKey &&
it.visitedAt == historyMetadata.visitedAt
} != null
}.also {
if (it == current.items.size) {
isPendingDeletion = true
}
}
}
}
else -> {}
}
}
// Add or remove the header and position to the map depending on it's deletion status // Add or remove the header and position to the map depending on it's deletion status
if (itemsWithHeaders.containsKey(current.historyTimeGroup)) { if (itemsWithHeaders.containsKey(current.historyTimeGroup)) {
@ -60,11 +103,33 @@ class HistoryAdapter(
timeGroup = current.historyTimeGroup timeGroup = current.historyTimeGroup
} }
holder.bind(current, timeGroup, position == 0, mode, isPendingDeletion) // If there is a single visible item, it's enough to change the empty state of the view.
if (isEmpty && !isPendingDeletion) {
isEmpty = false
onEmptyStateChanged.invoke(isEmpty)
} else if (position + 1 == itemCount) {
// If we reached the bottom of the list and there still has been zero visible items,
// we can can change the History view state to empty.
if (isEmpty) {
onEmptyStateChanged.invoke(isEmpty)
}
}
holder.bind(
current,
timeGroup,
position == 0,
mode,
isPendingDeletion,
groupPendingDeletionCount
)
} }
fun updatePendingDeletionIds(pendingDeletionIds: Set<Long>) { /**
this.pendingDeletionIds = pendingDeletionIds * @param pendingDeletionItems is used to filter out the items that should not be displayed.
*/
fun updatePendingDeletionItems(pendingDeletionItems: Set<PendingDeletionHistory>) {
this.pendingDeletionItems = pendingDeletionItems
} }
companion object { companion object {

@ -4,14 +4,21 @@
package org.mozilla.fenix.library.history package org.mozilla.fenix.library.history
import android.content.Context
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mozilla.telemetry.glean.private.NoExtras import mozilla.components.browser.state.action.HistoryMetadataAction
import mozilla.components.service.glean.private.NoExtras
import org.mozilla.fenix.GleanMetrics.Events import org.mozilla.fenix.GleanMetrics.Events
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.history.DefaultPagedHistoryProvider
import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.navigateSafe import org.mozilla.fenix.ext.navigateSafe
import org.mozilla.fenix.GleanMetrics.History as GleanHistory import org.mozilla.fenix.GleanMetrics.History as GleanHistory
@ -29,15 +36,21 @@ interface HistoryController {
fun handleEnterRecentlyClosed() fun handleEnterRecentlyClosed()
} }
@Suppress("TooManyFunctions") @Suppress("TooManyFunctions", "LongParameterList")
class DefaultHistoryController( class DefaultHistoryController(
private val store: HistoryFragmentStore, private val store: HistoryFragmentStore,
private val appStore: AppStore,
private var historyProvider: DefaultPagedHistoryProvider,
private val navController: NavController, private val navController: NavController,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val openToBrowser: (item: History.Regular) -> Unit, private val openToBrowser: (item: History.Regular) -> Unit,
private val displayDeleteAll: () -> Unit, private val displayDeleteAll: () -> Unit,
private val invalidateOptionsMenu: () -> Unit, private val invalidateOptionsMenu: () -> Unit,
private val deleteHistoryItems: (Set<History>) -> Unit, private val deleteSnackbar: (
items: Set<History>,
undo: suspend (Set<History>) -> Unit,
delete: (Set<History>) -> suspend (context: Context) -> Unit
) -> Unit,
private val syncHistory: suspend () -> Unit, private val syncHistory: suspend () -> Unit,
private val metrics: MetricController private val metrics: MetricController
) : HistoryController { ) : HistoryController {
@ -95,7 +108,39 @@ class DefaultHistoryController(
} }
override fun handleDeleteSome(items: Set<History>) { override fun handleDeleteSome(items: Set<History>) {
deleteHistoryItems.invoke(items) val pendingDeletionItems = items.map { it.toPendingDeletionHistory() }.toSet()
appStore.dispatch(AppAction.AddPendingDeletionSet(pendingDeletionItems))
deleteSnackbar.invoke(items, ::undo, ::delete)
}
private fun undo(items: Set<History>) {
val pendingDeletionItems = items.map { it.toPendingDeletionHistory() }.toSet()
appStore.dispatch(AppAction.UndoPendingDeletionSet(pendingDeletionItems))
}
private fun delete(items: Set<History>): suspend (context: Context) -> Unit {
return { context ->
CoroutineScope(Dispatchers.IO).launch {
store.dispatch(HistoryFragmentAction.EnterDeletionMode)
for (item in items) {
GleanHistory.removed.record(NoExtras())
when (item) {
is History.Regular -> context.components.core.historyStorage.deleteVisitsFor(item.url)
is History.Group -> {
// NB: If we have non-search groups, this logic needs to be updated.
historyProvider.deleteMetadataSearchGroup(item)
context.components.core.store.dispatch(
HistoryMetadataAction.DisbandSearchGroupAction(searchTerm = item.title)
)
}
// We won't encounter individual metadata entries outside of groups.
is History.Metadata -> {}
}
}
store.dispatch(HistoryFragmentAction.ExitDeletionMode)
}
}
} }
override fun handleRequestSync() { override fun handleRequestSync() {

@ -15,7 +15,6 @@ import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavDirections import androidx.navigation.NavDirections
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
@ -25,15 +24,16 @@ import androidx.paging.PagingData
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mozilla.components.browser.state.action.EngineAction import mozilla.components.browser.state.action.EngineAction
import mozilla.components.browser.state.action.HistoryMetadataAction
import mozilla.components.browser.state.action.RecentlyClosedAction import mozilla.components.browser.state.action.RecentlyClosedAction
import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.engine.prompt.ShareData import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.service.fxa.sync.SyncReason import mozilla.components.service.fxa.sync.SyncReason
import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.telemetry.glean.private.NoExtras import mozilla.telemetry.glean.private.NoExtras
@ -63,7 +63,6 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler {
private lateinit var historyInteractor: HistoryInteractor private lateinit var historyInteractor: HistoryInteractor
private lateinit var historyProvider: DefaultPagedHistoryProvider private lateinit var historyProvider: DefaultPagedHistoryProvider
private var userHasHistory = MutableLiveData(true)
private var history: Flow<PagingData<History>> = Pager( private var history: Flow<PagingData<History>> = Pager(
PagingConfig(PAGE_SIZE), PagingConfig(PAGE_SIZE),
null null
@ -91,19 +90,22 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler {
HistoryFragmentState( HistoryFragmentState(
items = listOf(), items = listOf(),
mode = HistoryFragmentState.Mode.Normal, mode = HistoryFragmentState.Mode.Normal,
pendingDeletionIds = emptySet(), pendingDeletionItems = emptySet(),
isEmpty = false,
isDeletingItems = false isDeletingItems = false
) )
) )
} }
val historyController: HistoryController = DefaultHistoryController( val historyController: HistoryController = DefaultHistoryController(
store = historyStore, store = historyStore,
appStore = requireContext().components.appStore,
historyProvider = historyProvider,
navController = findNavController(), navController = findNavController(),
scope = lifecycleScope, scope = lifecycleScope,
openToBrowser = ::openItem, openToBrowser = ::openItem,
displayDeleteAll = ::displayDeleteAllDialog, displayDeleteAll = ::displayDeleteAllDialog,
invalidateOptionsMenu = ::invalidateOptionsMenu, invalidateOptionsMenu = ::invalidateOptionsMenu,
deleteHistoryItems = ::deleteHistoryItems, deleteSnackbar = :: deleteSnackbar,
syncHistory = ::syncHistory, syncHistory = ::syncHistory,
metrics = requireComponents.analytics.metrics metrics = requireComponents.analytics.metrics
) )
@ -113,7 +115,16 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler {
_historyView = HistoryView( _historyView = HistoryView(
binding.historyLayout, binding.historyLayout,
historyInteractor, historyInteractor,
onZeroItemsLoaded = { userHasHistory.value = false } onZeroItemsLoaded = {
historyStore.dispatch(
HistoryFragmentAction.ChangeEmptyState(isEmpty = true)
)
},
onEmptyStateChanged = {
historyStore.dispatch(
HistoryFragmentAction.ChangeEmptyState(it)
)
}
) )
return view return view
@ -145,16 +156,19 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler {
setHasOptionsMenu(true) setHasOptionsMenu(true)
} }
private fun deleteHistoryItems(items: Set<History>) { private fun deleteSnackbar(
updatePendingHistoryToDelete(items) items: Set<History>,
undo: suspend (items: Set<History>) -> Unit,
delete: (Set<History>) -> suspend (context: Context) -> Unit
) {
CoroutineScope(IO).allowUndo( CoroutineScope(IO).allowUndo(
requireActivity().getRootView()!!, requireActivity().getRootView()!!,
getMultiSelectSnackBarMessage(items), getMultiSelectSnackBarMessage(items),
getString(R.string.bookmark_undo_deletion), getString(R.string.snackbar_deleted_undo),
{ {
undoPendingDeletion(items) undo.invoke(items)
}, },
getDeleteHistoryItemsOperation(items) delete(items)
) )
} }
@ -165,10 +179,13 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler {
historyView.update(it) historyView.update(it)
} }
userHasHistory.observe( requireContext().components.appStore.flowScoped(viewLifecycleOwner) { flow ->
viewLifecycleOwner, flow.mapNotNull { state -> state.pendingDeletionHistoryItems }.collect { items ->
historyView::updateEmptyState historyStore.dispatch(
) HistoryFragmentAction.UpdatePendingDeletionItems(pendingDeletionItems = items)
)
}
}
lifecycleScope.launch { lifecycleScope.launch {
history.collect { history.collect {
@ -228,7 +245,7 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler {
true true
} }
R.id.delete_history_multi_select -> { R.id.delete_history_multi_select -> {
deleteHistoryItems(historyStore.state.mode.selectedItems) historyInteractor.onDeleteSome(historyStore.state.mode.selectedItems)
historyStore.dispatch(HistoryFragmentAction.ExitEditMode) historyStore.dispatch(HistoryFragmentAction.ExitEditMode)
true true
} }
@ -362,47 +379,14 @@ class HistoryFragment : LibraryPageFragment<History>(), UserInteractionHandler {
) )
} }
private fun getDeleteHistoryItemsOperation(items: Set<History>): (suspend (context: Context) -> Unit) { @Suppress("UnusedPrivateMember")
return { context ->
CoroutineScope(IO).launch {
historyStore.dispatch(HistoryFragmentAction.EnterDeletionMode)
for (item in items) {
GleanHistory.removed.record(NoExtras())
when (item) {
is History.Regular -> context.components.core.historyStorage.deleteVisitsFor(item.url)
is History.Group -> {
// NB: If we have non-search groups, this logic needs to be updated.
historyProvider.deleteMetadataSearchGroup(item)
context.components.core.store.dispatch(
HistoryMetadataAction.DisbandSearchGroupAction(searchTerm = item.title)
)
}
// We won't encounter individual metadata entries outside of groups.
is History.Metadata -> {}
}
}
historyStore.dispatch(HistoryFragmentAction.ExitDeletionMode)
}
}
}
private fun updatePendingHistoryToDelete(items: Set<History>) {
val ids = items.map { item -> item.visitedAt }.toSet()
historyStore.dispatch(HistoryFragmentAction.AddPendingDeletionSet(ids))
}
private fun undoPendingDeletion(items: Set<History>) {
val ids = items.map { item -> item.visitedAt }.toSet()
historyStore.dispatch(HistoryFragmentAction.UndoPendingDeletionSet(ids))
}
private suspend fun syncHistory() { private suspend fun syncHistory() {
val accountManager = requireComponents.backgroundServices.accountManager val accountManager = requireComponents.backgroundServices.accountManager
accountManager.syncNow(SyncReason.User) accountManager.syncNow(SyncReason.User)
historyView.historyAdapter.refresh() historyView.historyAdapter.refresh()
} }
@Suppress("UnusedPrivateMember")
companion object { companion object {
private const val PAGE_SIZE = 25 private const val PAGE_SIZE = 25
} }

@ -33,7 +33,8 @@ sealed class History : Parcelable {
* @property historyTimeGroup [HistoryItemTimeGroup] of the history item. * @property historyTimeGroup [HistoryItemTimeGroup] of the history item.
* @property selected Whether or not the history item is selected. * @property selected Whether or not the history item is selected.
*/ */
@Parcelize data class Regular( @Parcelize
data class Regular(
override val position: Int, override val position: Int,
override val title: String, override val title: String,
val url: String, val url: String,
@ -55,7 +56,8 @@ sealed class History : Parcelable {
* was opened from history. * was opened from history.
* @property selected Whether or not the history metadata item is selected. * @property selected Whether or not the history metadata item is selected.
*/ */
@Parcelize data class Metadata( @Parcelize
data class Metadata(
override val position: Int, override val position: Int,
override val title: String, override val title: String,
val url: String, val url: String,
@ -76,7 +78,8 @@ sealed class History : Parcelable {
* @property items List of history metadata items associated with the group. * @property items List of history metadata items associated with the group.
* @property selected Whether or not the history group is selected. * @property selected Whether or not the history group is selected.
*/ */
@Parcelize data class Group( @Parcelize
data class Group(
override val position: Int, override val position: Int,
override val title: String, override val title: String,
override val visitedAt: Long, override val visitedAt: Long,
@ -115,8 +118,16 @@ sealed class HistoryFragmentAction : Action {
object ExitEditMode : HistoryFragmentAction() object ExitEditMode : HistoryFragmentAction()
data class AddItemForRemoval(val item: History) : HistoryFragmentAction() data class AddItemForRemoval(val item: History) : HistoryFragmentAction()
data class RemoveItemForRemoval(val item: History) : HistoryFragmentAction() data class RemoveItemForRemoval(val item: History) : HistoryFragmentAction()
data class AddPendingDeletionSet(val itemIds: Set<Long>) : HistoryFragmentAction() /**
data class UndoPendingDeletionSet(val itemIds: Set<Long>) : HistoryFragmentAction() * Updates the empty state of [org.mozilla.fenix.library.history.HistoryView].
*/
data class ChangeEmptyState(val isEmpty: Boolean) : HistoryFragmentAction()
/**
* Updates the set of items marked for removal from the [org.mozilla.fenix.components.AppStore]
* to the [HistoryFragmentStore], to be hidden from the UI.
*/
data class UpdatePendingDeletionItems(val pendingDeletionItems: Set<PendingDeletionHistory>) :
HistoryFragmentAction()
object EnterDeletionMode : HistoryFragmentAction() object EnterDeletionMode : HistoryFragmentAction()
object ExitDeletionMode : HistoryFragmentAction() object ExitDeletionMode : HistoryFragmentAction()
object StartSync : HistoryFragmentAction() object StartSync : HistoryFragmentAction()
@ -131,7 +142,8 @@ sealed class HistoryFragmentAction : Action {
data class HistoryFragmentState( data class HistoryFragmentState(
val items: List<History>, val items: List<History>,
val mode: Mode, val mode: Mode,
val pendingDeletionIds: Set<Long>, val pendingDeletionItems: Set<PendingDeletionHistory>,
val isEmpty: Boolean,
val isDeletingItems: Boolean val isDeletingItems: Boolean
) : State { ) : State {
sealed class Mode { sealed class Mode {
@ -168,13 +180,9 @@ private fun historyStateReducer(
is HistoryFragmentAction.ExitDeletionMode -> state.copy(isDeletingItems = false) is HistoryFragmentAction.ExitDeletionMode -> state.copy(isDeletingItems = false)
is HistoryFragmentAction.StartSync -> state.copy(mode = HistoryFragmentState.Mode.Syncing) is HistoryFragmentAction.StartSync -> state.copy(mode = HistoryFragmentState.Mode.Syncing)
is HistoryFragmentAction.FinishSync -> state.copy(mode = HistoryFragmentState.Mode.Normal) is HistoryFragmentAction.FinishSync -> state.copy(mode = HistoryFragmentState.Mode.Normal)
is HistoryFragmentAction.AddPendingDeletionSet -> is HistoryFragmentAction.ChangeEmptyState -> state.copy(isEmpty = action.isEmpty)
state.copy( is HistoryFragmentAction.UpdatePendingDeletionItems -> state.copy(
pendingDeletionIds = state.pendingDeletionIds + action.itemIds pendingDeletionItems = action.pendingDeletionItems
) )
is HistoryFragmentAction.UndoPendingDeletionSet ->
state.copy(
pendingDeletionIds = state.pendingDeletionIds - action.itemIds
)
} }
} }

@ -6,6 +6,7 @@ package org.mozilla.fenix.library.history
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -23,7 +24,8 @@ import org.mozilla.fenix.theme.ThemeManager
class HistoryView( class HistoryView(
container: ViewGroup, container: ViewGroup,
val interactor: HistoryInteractor, val interactor: HistoryInteractor,
val onZeroItemsLoaded: () -> Unit val onZeroItemsLoaded: () -> Unit,
val onEmptyStateChanged: (Boolean) -> Unit
) : LibraryPageView(container), UserInteractionHandler { ) : LibraryPageView(container), UserInteractionHandler {
val binding = ComponentHistoryBinding.inflate( val binding = ComponentHistoryBinding.inflate(
@ -33,7 +35,9 @@ class HistoryView(
var mode: HistoryFragmentState.Mode = HistoryFragmentState.Mode.Normal var mode: HistoryFragmentState.Mode = HistoryFragmentState.Mode.Normal
private set private set
val historyAdapter = HistoryAdapter(interactor).apply { val historyAdapter = HistoryAdapter(interactor) { isEmpty ->
onEmptyStateChanged(isEmpty)
}.apply {
addLoadStateListener { addLoadStateListener {
// First call will always have itemCount == 0, but we want to keep adapterItemCount // First call will always have itemCount == 0, but we want to keep adapterItemCount
// as null until we can distinguish an empty list from populated, so updateEmptyState() // as null until we can distinguish an empty list from populated, so updateEmptyState()
@ -59,8 +63,7 @@ class HistoryView(
(itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false (itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
} }
val primaryTextColor = val primaryTextColor = ThemeManager.resolveAttribute(R.attr.textPrimary, context)
ThemeManager.resolveAttribute(R.attr.textPrimary, context)
binding.swipeRefresh.setColorSchemeColors(primaryTextColor) binding.swipeRefresh.setColorSchemeColors(primaryTextColor)
binding.swipeRefresh.setOnRefreshListener { binding.swipeRefresh.setOnRefreshListener {
interactor.onRequestSync() interactor.onRequestSync()
@ -76,12 +79,14 @@ class HistoryView(
state.mode === HistoryFragmentState.Mode.Normal || state.mode === HistoryFragmentState.Mode.Syncing state.mode === HistoryFragmentState.Mode.Normal || state.mode === HistoryFragmentState.Mode.Syncing
mode = state.mode mode = state.mode
historyAdapter.updatePendingDeletionIds(state.pendingDeletionIds) historyAdapter.updatePendingDeletionItems(state.pendingDeletionItems)
updateEmptyState(state.pendingDeletionIds.size != adapterItemCount) updateEmptyState(userHasHistory = !state.isEmpty)
historyAdapter.updateMode(state.mode) historyAdapter.updateMode(state.mode)
val first = layoutManager.findFirstVisibleItemPosition() // We want to update the one item above the upper border of the screen, because
// RecyclerView won't redraw it on scroll and onBindViewHolder() method won't be called.
val first = layoutManager.findFirstVisibleItemPosition() - 1
val last = layoutManager.findLastVisibleItemPosition() + 1 val last = layoutManager.findLastVisibleItemPosition() + 1
historyAdapter.notifyItemRangeChanged(first, last - first) historyAdapter.notifyItemRangeChanged(first, last - first)
@ -89,14 +94,6 @@ class HistoryView(
interactor.onModeSwitched() interactor.onModeSwitched()
} }
if (state.mode is HistoryFragmentState.Mode.Editing) {
val unselectedItems = oldMode.selectedItems - state.mode.selectedItems
state.mode.selectedItems.union(unselectedItems).forEach { item ->
historyAdapter.notifyItemChanged(item.position)
}
}
when (val mode = state.mode) { when (val mode = state.mode) {
is HistoryFragmentState.Mode.Normal -> { is HistoryFragmentState.Mode.Normal -> {
setUiForNormalMode( setUiForNormalMode(
@ -114,8 +111,8 @@ class HistoryView(
} }
} }
fun updateEmptyState(userHasHistory: Boolean) { private fun updateEmptyState(userHasHistory: Boolean) {
binding.historyList.isVisible = userHasHistory binding.historyList.isInvisible = !userHasHistory
binding.historyEmptyView.isVisible = !userHasHistory binding.historyEmptyView.isVisible = !userHasHistory
with(binding.recentlyClosedNavEmpty) { with(binding.recentlyClosedNavEmpty) {
recentlyClosedNav.setOnClickListener { recentlyClosedNav.setOnClickListener {

@ -0,0 +1,58 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.library.history
import mozilla.components.concept.storage.HistoryMetadataKey
/**
* Wrapper for the data of the history item that has been marked for removal. Undo snackbar delays
* the actual removal, while this class is used to match History items that should be hidden in the
* UI.
*/
sealed class PendingDeletionHistory {
/**
* This class represents a single, separate item in the history list.
*/
data class Item(
val visitedAt: Long,
val url: String
) : PendingDeletionHistory()
/**
* This class represents a group in the history list.
*/
data class Group(
val visitedAt: Long,
val historyMetadata: List<MetaData>
) : PendingDeletionHistory()
/**
* This class represents an item inside a group in the group history list
*/
data class MetaData(
val visitedAt: Long,
val key: HistoryMetadataKey
) : PendingDeletionHistory()
}
/**
* Maps an instance of [History] to an instance of [PendingDeletionHistory].
*/
fun History.toPendingDeletionHistory(): PendingDeletionHistory {
return when (this) {
is History.Regular -> PendingDeletionHistory.Item(visitedAt = visitedAt, url = url)
is History.Group -> PendingDeletionHistory.Group(
visitedAt = visitedAt,
historyMetadata = items.map { historyMetadata ->
PendingDeletionHistory.MetaData(
historyMetadata.visitedAt,
historyMetadata.historyMetadataKey
)
}
)
is History.Metadata -> PendingDeletionHistory.MetaData(visitedAt, historyMetadataKey)
}
}

@ -43,18 +43,25 @@ class HistoryListItemViewHolder(
} }
} }
/**
* Displays the data of the given history record.
* @param timeGroup used to form headers for different time frames, like today, yesterday, etc.
* @param showTopContent enables the Recent tab button.
* @param mode switches between editing and regular modes.
* @param isPendingDeletion hides the item unless an undo snackbar action is evoked.
* @param groupPendingDeletionCount allows to properly display the number of items inside a
* history group, taking into account pending removal of items inside.
*/
@Suppress("LongParameterList")
fun bind( fun bind(
item: History, item: History,
timeGroup: HistoryItemTimeGroup?, timeGroup: HistoryItemTimeGroup?,
showTopContent: Boolean, showTopContent: Boolean,
mode: HistoryFragmentState.Mode, mode: HistoryFragmentState.Mode,
isPendingDeletion: Boolean = false, isPendingDeletion: Boolean,
groupPendingDeletionCount: Int
) { ) {
if (isPendingDeletion) { binding.historyLayout.isVisible = !isPendingDeletion
binding.historyLayout.visibility = View.GONE
} else {
binding.historyLayout.visibility = View.VISIBLE
}
binding.historyLayout.titleView.text = item.title binding.historyLayout.titleView.text = item.title
@ -62,7 +69,7 @@ class HistoryListItemViewHolder(
is History.Regular -> item.url is History.Regular -> item.url
is History.Metadata -> item.url is History.Metadata -> item.url
is History.Group -> { is History.Group -> {
val numChildren = item.items.size val numChildren = item.items.size - groupPendingDeletionCount
val stringId = if (numChildren == 1) { val stringId = if (numChildren == 1) {
R.string.history_search_group_site R.string.history_search_group_site
} else { } else {

@ -4,6 +4,8 @@
package org.mozilla.fenix.library.historymetadata package org.mozilla.fenix.library.historymetadata
import android.content.Context
import android.content.DialogInterface
import android.os.Bundle import android.os.Bundle
import android.text.SpannableString import android.text.SpannableString
import android.view.LayoutInflater import android.view.LayoutInflater
@ -12,13 +14,19 @@ import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope import androidx.appcompat.app.AlertDialog
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.support.base.feature.UserInteractionHandler import mozilla.components.support.base.feature.UserInteractionHandler
import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.addons.showSnackBar
import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.components.StoreProvider import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.databinding.FragmentHistoryMetadataGroupBinding import org.mozilla.fenix.databinding.FragmentHistoryMetadataGroupBinding
@ -27,16 +35,19 @@ import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.setTextColor import org.mozilla.fenix.ext.setTextColor
import org.mozilla.fenix.ext.showToolbar import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.toShortUrl
import org.mozilla.fenix.library.LibraryPageFragment import org.mozilla.fenix.library.LibraryPageFragment
import org.mozilla.fenix.library.history.History import org.mozilla.fenix.library.history.History
import org.mozilla.fenix.library.historymetadata.controller.DefaultHistoryMetadataGroupController import org.mozilla.fenix.library.historymetadata.controller.DefaultHistoryMetadataGroupController
import org.mozilla.fenix.library.historymetadata.interactor.DefaultHistoryMetadataGroupInteractor import org.mozilla.fenix.library.historymetadata.interactor.DefaultHistoryMetadataGroupInteractor
import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor
import org.mozilla.fenix.library.historymetadata.view.HistoryMetadataGroupView import org.mozilla.fenix.library.historymetadata.view.HistoryMetadataGroupView
import org.mozilla.fenix.utils.allowUndo
/** /**
* Displays a list of history metadata items for a history metadata search group. * Displays a list of history metadata items for a history metadata search group.
*/ */
@SuppressWarnings("TooManyFunctions")
class HistoryMetadataGroupFragment : class HistoryMetadataGroupFragment :
LibraryPageFragment<History.Metadata>(), UserInteractionHandler { LibraryPageFragment<History.Metadata>(), UserInteractionHandler {
@ -51,6 +62,9 @@ class HistoryMetadataGroupFragment :
private val args by navArgs<HistoryMetadataGroupFragmentArgs>() private val args by navArgs<HistoryMetadataGroupFragmentArgs>()
override val selectedItems: Set<History.Metadata>
get() = historyMetadataGroupStore.state.items.filter { it.selected }.toSet()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setHasOptionsMenu(true) setHasOptionsMenu(true)
@ -63,10 +77,13 @@ class HistoryMetadataGroupFragment :
): View { ): View {
_binding = FragmentHistoryMetadataGroupBinding.inflate(inflater, container, false) _binding = FragmentHistoryMetadataGroupBinding.inflate(inflater, container, false)
val historyItems = args.historyMetadataItems.filterIsInstance<History.Metadata>()
historyMetadataGroupStore = StoreProvider.get(this) { historyMetadataGroupStore = StoreProvider.get(this) {
HistoryMetadataGroupFragmentStore( HistoryMetadataGroupFragmentStore(
HistoryMetadataGroupFragmentState( HistoryMetadataGroupFragmentState(
items = args.historyMetadataItems.filterIsInstance<History.Metadata>() items = historyItems,
pendingDeletionItems = requireContext().components.appStore.state.pendingDeletionHistoryItems,
isEmpty = historyItems.isEmpty()
) )
) )
} }
@ -75,18 +92,26 @@ class HistoryMetadataGroupFragment :
controller = DefaultHistoryMetadataGroupController( controller = DefaultHistoryMetadataGroupController(
historyStorage = (activity as HomeActivity).components.core.historyStorage, historyStorage = (activity as HomeActivity).components.core.historyStorage,
browserStore = (activity as HomeActivity).components.core.store, browserStore = (activity as HomeActivity).components.core.store,
appStore = requireContext().components.appStore,
store = historyMetadataGroupStore, store = historyMetadataGroupStore,
selectOrAddUseCase = requireComponents.useCases.tabsUseCases.selectOrAddTab, selectOrAddUseCase = requireComponents.useCases.tabsUseCases.selectOrAddTab,
navController = findNavController(), navController = findNavController(),
scope = lifecycleScope, searchTerm = args.title,
searchTerm = args.title deleteSnackbar = :: deleteSnackbar,
promptDeleteAll = :: promptDeleteAll,
allDeletedSnackbar = ::allDeletedSnackbar,
) )
) )
_historyMetadataGroupView = HistoryMetadataGroupView( _historyMetadataGroupView = HistoryMetadataGroupView(
container = binding.historyMetadataGroupLayout, container = binding.historyMetadataGroupLayout,
interactor = interactor, interactor = interactor,
title = args.title title = args.title,
onEmptyStateChanged = {
historyMetadataGroupStore.dispatch(
HistoryMetadataGroupFragmentAction.ChangeEmptyState(it)
)
}
) )
return binding.root return binding.root
@ -97,6 +122,16 @@ class HistoryMetadataGroupFragment :
historyMetadataGroupView.update(state) historyMetadataGroupView.update(state)
activity?.invalidateOptionsMenu() activity?.invalidateOptionsMenu()
} }
requireContext().components.appStore.flowScoped(viewLifecycleOwner) { flow ->
flow.map { state -> state.pendingDeletionHistoryItems }.collect { items ->
historyMetadataGroupStore.dispatch(
HistoryMetadataGroupFragmentAction.UpdatePendingDeletionItems(
pendingDeletionItems = items
)
)
}
}
} }
override fun onResume() { override fun onResume() {
@ -104,6 +139,14 @@ class HistoryMetadataGroupFragment :
showToolbar(args.title) showToolbar(args.title)
} }
override fun onDestroyView() {
super.onDestroyView()
_historyMetadataGroupView = null
_binding = null
}
override fun onBackPressed(): Boolean = interactor.onBackPressed(selectedItems)
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
if (selectedItems.isNotEmpty()) { if (selectedItems.isNotEmpty()) {
inflater.inflate(R.menu.history_select_multi, menu) inflater.inflate(R.menu.history_select_multi, menu)
@ -157,17 +200,42 @@ class HistoryMetadataGroupFragment :
} }
} }
override fun onDestroyView() { private fun deleteSnackbar(
super.onDestroyView() items: Set<History.Metadata>,
_historyMetadataGroupView = null undo: suspend (items: Set<History.Metadata>) -> Unit,
_binding = null delete: (Set<History.Metadata>) -> suspend (context: Context) -> Unit
) {
CoroutineScope(Dispatchers.IO).allowUndo(
requireView(),
getSnackBarMessage(items),
getString(R.string.snackbar_deleted_undo),
{
undo.invoke(items)
},
delete(items)
)
} }
override val selectedItems: Set<History.Metadata> private fun promptDeleteAll(delete: () -> Unit) {
get() = AlertDialog.Builder(requireContext()).apply {
historyMetadataGroupStore.state.items.filter { it.selected }.toSet() setMessage(R.string.delete_history_group_prompt_message)
setNegativeButton(R.string.delete_history_group_prompt_cancel) { dialog: DialogInterface, _ ->
dialog.cancel()
}
setPositiveButton(R.string.delete_history_group_prompt_allow) { dialog: DialogInterface, _ ->
delete.invoke()
dialog.dismiss()
}
create()
}.show()
}
override fun onBackPressed(): Boolean = interactor.onBackPressed(selectedItems) private fun allDeletedSnackbar() {
showSnackBar(
requireView(),
getString(R.string.delete_history_group_snackbar)
)
}
private fun showTabTray() { private fun showTabTray() {
findNavController().nav( findNavController().nav(
@ -175,4 +243,12 @@ class HistoryMetadataGroupFragment :
HistoryMetadataGroupFragmentDirections.actionGlobalTabsTrayFragment() HistoryMetadataGroupFragmentDirections.actionGlobalTabsTrayFragment()
) )
} }
private fun getSnackBarMessage(historyItems: Set<History.Metadata>): String {
val historyItem = historyItems.first()
return String.format(
requireContext().getString(R.string.history_delete_single_item_snackbar),
historyItem.url.toShortUrl(requireComponents.publicSuffixList)
)
}
} }

@ -8,6 +8,7 @@ import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store import mozilla.components.lib.state.Store
import org.mozilla.fenix.library.history.History import org.mozilla.fenix.library.history.History
import org.mozilla.fenix.library.history.PendingDeletionHistory
/** /**
* The [Store] for holding the [HistoryMetadataGroupFragmentState] and applying * The [Store] for holding the [HistoryMetadataGroupFragmentState] and applying
@ -28,9 +29,19 @@ sealed class HistoryMetadataGroupFragmentAction : Action {
HistoryMetadataGroupFragmentAction() HistoryMetadataGroupFragmentAction()
data class Select(val item: History.Metadata) : HistoryMetadataGroupFragmentAction() data class Select(val item: History.Metadata) : HistoryMetadataGroupFragmentAction()
data class Deselect(val item: History.Metadata) : HistoryMetadataGroupFragmentAction() data class Deselect(val item: History.Metadata) : HistoryMetadataGroupFragmentAction()
/**
* Updates the set of items marked for removal from the [org.mozilla.fenix.components.AppStore]
* to the [HistoryMetadataGroupFragmentStore], to be hidden from the UI.
*/
data class UpdatePendingDeletionItems(val pendingDeletionItems: Set<PendingDeletionHistory>) :
HistoryMetadataGroupFragmentAction()
object DeselectAll : HistoryMetadataGroupFragmentAction() object DeselectAll : HistoryMetadataGroupFragmentAction()
data class Delete(val item: History.Metadata) : HistoryMetadataGroupFragmentAction() data class Delete(val item: History.Metadata) : HistoryMetadataGroupFragmentAction()
object DeleteAll : HistoryMetadataGroupFragmentAction() object DeleteAll : HistoryMetadataGroupFragmentAction()
/**
* Updates the empty state of [org.mozilla.fenix.library.historymetadata.view.HistoryMetadataGroupView].
*/
data class ChangeEmptyState(val isEmpty: Boolean) : HistoryMetadataGroupFragmentAction()
} }
/** /**
@ -39,7 +50,9 @@ sealed class HistoryMetadataGroupFragmentAction : Action {
* @property items The list of [History.Metadata] to display. * @property items The list of [History.Metadata] to display.
*/ */
data class HistoryMetadataGroupFragmentState( data class HistoryMetadataGroupFragmentState(
val items: List<History.Metadata> = emptyList() val items: List<History.Metadata>,
val pendingDeletionItems: Set<PendingDeletionHistory>,
val isEmpty: Boolean,
) : State ) : State
/** /**
@ -91,5 +104,10 @@ private fun historyStateReducer(
} }
is HistoryMetadataGroupFragmentAction.DeleteAll -> is HistoryMetadataGroupFragmentAction.DeleteAll ->
state.copy(items = emptyList()) state.copy(items = emptyList())
is HistoryMetadataGroupFragmentAction.UpdatePendingDeletionItems ->
state.copy(pendingDeletionItems = action.pendingDeletionItems)
is HistoryMetadataGroupFragmentAction.ChangeEmptyState -> state.copy(
isEmpty = action.isEmpty
)
} }
} }

@ -4,17 +4,23 @@
package org.mozilla.fenix.library.historymetadata.controller package org.mozilla.fenix.library.historymetadata.controller
import android.content.Context
import androidx.navigation.NavController import androidx.navigation.NavController
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import mozilla.components.browser.state.action.HistoryMetadataAction import mozilla.components.browser.state.action.HistoryMetadataAction
import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.storage.sync.PlacesHistoryStorage import mozilla.components.browser.storage.sync.PlacesHistoryStorage
import mozilla.components.concept.engine.prompt.ShareData import mozilla.components.concept.engine.prompt.ShareData
import mozilla.components.feature.tabs.TabsUseCases import mozilla.components.feature.tabs.TabsUseCases
import mozilla.components.service.glean.private.NoExtras
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.ext.components
import mozilla.components.service.glean.private.NoExtras
import org.mozilla.fenix.library.history.History import org.mozilla.fenix.library.history.History
import org.mozilla.fenix.library.history.toPendingDeletionHistory
import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentAction import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentAction
import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentDirections import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentDirections
import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentStore import org.mozilla.fenix.library.historymetadata.HistoryMetadataGroupFragmentStore
@ -79,11 +85,18 @@ interface HistoryMetadataGroupController {
class DefaultHistoryMetadataGroupController( class DefaultHistoryMetadataGroupController(
private val historyStorage: PlacesHistoryStorage, private val historyStorage: PlacesHistoryStorage,
private val browserStore: BrowserStore, private val browserStore: BrowserStore,
private val appStore: AppStore,
private val store: HistoryMetadataGroupFragmentStore, private val store: HistoryMetadataGroupFragmentStore,
private val selectOrAddUseCase: TabsUseCases.SelectOrAddUseCase, private val selectOrAddUseCase: TabsUseCases.SelectOrAddUseCase,
private val navController: NavController, private val navController: NavController,
private val scope: CoroutineScope,
private val searchTerm: String, private val searchTerm: String,
private val deleteSnackbar: (
items: Set<History.Metadata>,
undo: suspend (Set<History.Metadata>) -> Unit,
delete: (Set<History.Metadata>) -> suspend (context: Context) -> Unit
) -> Unit,
private val promptDeleteAll: (() -> Unit) -> Unit,
private val allDeletedSnackbar: () -> Unit
) : HistoryMetadataGroupController { ) : HistoryMetadataGroupController {
override fun handleOpen(item: History.Metadata) { override fun handleOpen(item: History.Metadata) {
@ -118,25 +131,42 @@ class DefaultHistoryMetadataGroupController(
} }
override fun handleDelete(items: Set<History.Metadata>) { override fun handleDelete(items: Set<History.Metadata>) {
scope.launch { val pendingDeletionItems = items.map { it.toPendingDeletionHistory() }.toSet()
val isDeletingLastItem = items.containsAll(store.state.items) appStore.dispatch(AppAction.AddPendingDeletionSet(pendingDeletionItems))
items.forEach { deleteSnackbar.invoke(items, ::undo, ::delete)
store.dispatch(HistoryMetadataGroupFragmentAction.Delete(it)) }
historyStorage.deleteVisitsFor(it.url)
GleanHistory.searchTermGroupRemoveTab.record(NoExtras()) private fun undo(items: Set<History.Metadata>) {
} val pendingDeletionItems = items.map { it.toPendingDeletionHistory() }.toSet()
// The method is called for both single and multiple items. appStore.dispatch(AppAction.UndoPendingDeletionSet(pendingDeletionItems))
// In case all items have been deleted, we have to disband the search group. }
if (isDeletingLastItem) {
browserStore.dispatch( private fun delete(items: Set<History.Metadata>): suspend (context: Context) -> Unit {
HistoryMetadataAction.DisbandSearchGroupAction(searchTerm = searchTerm) return { context ->
) CoroutineScope(IO).launch {
val isDeletingLastItem = items.containsAll(store.state.items)
items.forEach {
store.dispatch(HistoryMetadataGroupFragmentAction.Delete(it))
context.components.core.historyStorage.deleteVisitsFor(it.url)
GleanHistory.searchTermGroupRemoveTab.record(NoExtras())
}
// The method is called for both single and multiple items.
// In case all items have been deleted, we have to disband the search group.
if (isDeletingLastItem) {
context.components.core.store.dispatch(
HistoryMetadataAction.DisbandSearchGroupAction(searchTerm = searchTerm)
)
}
} }
} }
} }
override fun handleDeleteAll() { override fun handleDeleteAll() {
scope.launch { promptDeleteAll.invoke(::deleteAll)
}
private fun deleteAll() {
CoroutineScope(IO).launch {
store.dispatch(HistoryMetadataGroupFragmentAction.DeleteAll) store.dispatch(HistoryMetadataGroupFragmentAction.DeleteAll)
store.state.items.forEach { store.state.items.forEach {
historyStorage.deleteVisitsFor(it.url) historyStorage.deleteVisitsFor(it.url)
@ -145,6 +175,8 @@ class DefaultHistoryMetadataGroupController(
HistoryMetadataAction.DisbandSearchGroupAction(searchTerm = searchTerm) HistoryMetadataAction.DisbandSearchGroupAction(searchTerm = searchTerm)
) )
GleanHistory.searchTermGroupRemoveAll.record(NoExtras()) GleanHistory.searchTermGroupRemoveAll.record(NoExtras())
allDeletedSnackbar.invoke()
navController.popBackStack()
} }
} }
} }

@ -9,6 +9,7 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.ListAdapter
import org.mozilla.fenix.library.history.History import org.mozilla.fenix.library.history.History
import org.mozilla.fenix.library.history.PendingDeletionHistory
import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroupInteractor
import org.mozilla.fenix.selection.SelectionHolder import org.mozilla.fenix.selection.SelectionHolder
@ -16,11 +17,14 @@ import org.mozilla.fenix.selection.SelectionHolder
* Adapter for a list of history metadata items to be displayed. * Adapter for a list of history metadata items to be displayed.
*/ */
class HistoryMetadataGroupAdapter( class HistoryMetadataGroupAdapter(
private val interactor: HistoryMetadataGroupInteractor private val interactor: HistoryMetadataGroupInteractor,
private val onEmptyStateChanged: (Boolean) -> Unit,
) : ListAdapter<History.Metadata, HistoryMetadataGroupItemViewHolder>(DiffCallback), ) : ListAdapter<History.Metadata, HistoryMetadataGroupItemViewHolder>(DiffCallback),
SelectionHolder<History.Metadata> { SelectionHolder<History.Metadata> {
private var selectedHistoryItems: Set<History.Metadata> = emptySet() private var selectedHistoryItems: Set<History.Metadata> = emptySet()
private var pendingDeletionItems = emptySet<PendingDeletionHistory>()
private var isEmpty = true
override val selectedItems: Set<History.Metadata> override val selectedItems: Set<History.Metadata>
get() = selectedHistoryItems get() = selectedHistoryItems
@ -34,16 +38,55 @@ class HistoryMetadataGroupAdapter(
return HistoryMetadataGroupItemViewHolder(view, interactor, this) return HistoryMetadataGroupItemViewHolder(view, interactor, this)
} }
override fun getItemId(position: Int): Long {
return getItem(position).visitedAt
}
override fun onBindViewHolder(holder: HistoryMetadataGroupItemViewHolder, position: Int) { override fun onBindViewHolder(holder: HistoryMetadataGroupItemViewHolder, position: Int) {
holder.bind(getItem(position)) val current = getItem(position) ?: return
if (position == 0) {
isEmpty = true
}
val isPendingDeletion = pendingDeletionItems.any {
it is PendingDeletionHistory.MetaData &&
it.key == current.historyMetadataKey &&
it.visitedAt == current.visitedAt
}
// If there is a single visible item, it's enough to change the empty state of the view.
if (isEmpty && !isPendingDeletion) {
isEmpty = false
onEmptyStateChanged.invoke(isEmpty)
} else if (position + 1 == itemCount) {
// If we reached the bottom of the list and there still has been zero visible items,
// we can can change the History Group view state to empty.
if (isEmpty) {
onEmptyStateChanged.invoke(isEmpty)
}
}
holder.bind(getItem(position), isPendingDeletion)
} }
fun updateData(items: List<History.Metadata>) { fun updateData(items: List<History.Metadata>) {
this.selectedHistoryItems = items.filter { it.selected }.toSet()
notifyItemRangeChanged(0, items.size)
submitList(items) submitList(items)
} }
/**
* @param selectedHistoryItems is used to keep track of the items selected by the user.
*/
fun updateSelectedItems(selectedHistoryItems: Set<History.Metadata>) {
this.selectedHistoryItems = selectedHistoryItems
}
/**
* @param pendingDeletionItems is used to keep track of the items selected by the user.
*/
fun updatePendingDeletionItems(pendingDeletionItems: Set<PendingDeletionHistory>) {
this.pendingDeletionItems = pendingDeletionItems
}
internal object DiffCallback : DiffUtil.ItemCallback<History.Metadata>() { internal object DiffCallback : DiffUtil.ItemCallback<History.Metadata>() {
override fun areContentsTheSame(oldItem: History.Metadata, newItem: History.Metadata): Boolean = override fun areContentsTheSame(oldItem: History.Metadata, newItem: History.Metadata): Boolean =
oldItem.position == newItem.position oldItem.position == newItem.position

@ -5,6 +5,7 @@
package org.mozilla.fenix.library.historymetadata.view package org.mozilla.fenix.library.historymetadata.view
import android.view.View import android.view.View
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.HistoryMetadataGroupListItemBinding import org.mozilla.fenix.databinding.HistoryMetadataGroupListItemBinding
@ -38,7 +39,12 @@ class HistoryMetadataGroupItemViewHolder(
} }
} }
fun bind(item: History.Metadata) { /**
* Displays the data of the given history record.
* @param isPendingDeletion hides the item unless user evokes Undo snackbar action.
*/
fun bind(item: History.Metadata, isPendingDeletion: Boolean) {
binding.historyLayout.isVisible = !isPendingDeletion
binding.historyLayout.titleView.text = item.title binding.historyLayout.titleView.text = item.title
binding.historyLayout.urlView.text = item.url binding.historyLayout.urlView.text = item.url

@ -6,8 +6,10 @@ package org.mozilla.fenix.library.historymetadata.view
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.ComponentHistoryMetadataGroupBinding import org.mozilla.fenix.databinding.ComponentHistoryMetadataGroupBinding
import org.mozilla.fenix.library.LibraryPageView import org.mozilla.fenix.library.LibraryPageView
@ -20,19 +22,28 @@ import org.mozilla.fenix.library.historymetadata.interactor.HistoryMetadataGroup
class HistoryMetadataGroupView( class HistoryMetadataGroupView(
container: ViewGroup, container: ViewGroup,
val interactor: HistoryMetadataGroupInteractor, val interactor: HistoryMetadataGroupInteractor,
val title: String val title: String,
val onEmptyStateChanged: (Boolean) -> Unit,
) : LibraryPageView(container) { ) : LibraryPageView(container) {
private val binding = ComponentHistoryMetadataGroupBinding.inflate( private val binding = ComponentHistoryMetadataGroupBinding.inflate(
LayoutInflater.from(container.context), container, true LayoutInflater.from(container.context), container, true
) )
private val historyMetadataGroupAdapter = HistoryMetadataGroupAdapter(interactor) private val historyMetadataGroupAdapter = HistoryMetadataGroupAdapter(interactor) { isEmpty ->
onEmptyStateChanged(isEmpty)
}.apply {
setHasStableIds(true)
}
private var layoutManager: LinearLayoutManager
init { init {
binding.historyMetadataGroupList.apply { binding.historyMetadataGroupList.apply {
layoutManager = LinearLayoutManager(containerView.context) layoutManager = LinearLayoutManager(containerView.context).also {
this@HistoryMetadataGroupView.layoutManager = it
}
adapter = historyMetadataGroupAdapter adapter = historyMetadataGroupAdapter
(itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
} }
} }
@ -41,18 +52,44 @@ class HistoryMetadataGroupView(
* [HistoryMetadataGroupFragmentState]. * [HistoryMetadataGroupFragmentState].
*/ */
fun update(state: HistoryMetadataGroupFragmentState) { fun update(state: HistoryMetadataGroupFragmentState) {
binding.historyMetadataGroupList.isVisible = state.items.isNotEmpty() binding.historyMetadataGroupList.isInvisible = state.isEmpty
binding.historyMetadataGroupEmptyView.isVisible = state.items.isEmpty() binding.historyMetadataGroupEmptyView.isVisible = state.isEmpty
val selectedHistoryItems = state.items.filter {
it.selected
}.toSet()
historyMetadataGroupAdapter.updatePendingDeletionItems(state.pendingDeletionItems)
historyMetadataGroupAdapter.updateSelectedItems(selectedHistoryItems)
historyMetadataGroupAdapter.updateData(state.items) historyMetadataGroupAdapter.updateData(state.items)
val selectedItems = state.items.filter { it.selected } var first = layoutManager.findFirstVisibleItemPosition()
val last = layoutManager.findLastVisibleItemPosition()
// We want to adjust the position of the first visible in order to update the one item above
// the edge of the screen. It's an edge case, when the item partially visible is being
// removed. Currently, Undo action won't make it visible again.
// This block should be above the itemCount calculation, otherwise bottom partially visible
// item won't be updated.
if (first > 0) {
--first
}
// In case there are no visible items, we still have to request updating two items, to cover
// the case when the last item has been removed and Undo action was called.
val itemCount = if (last != -1) {
(last - first) + 1
} else {
2
}
historyMetadataGroupAdapter.notifyItemRangeChanged(first, itemCount)
if (selectedItems.isEmpty()) { if (selectedHistoryItems.isEmpty()) {
setUiForNormalMode(title) setUiForNormalMode(title)
} else { } else {
setUiForSelectingMode( setUiForSelectingMode(
context.getString(R.string.history_multi_select_title, selectedItems.size) context.getString(R.string.history_multi_select_title, selectedHistoryItems.size)
) )
} }
} }

@ -1138,6 +1138,15 @@
<!-- Text for the snackbar to show the user that the deletion of browsing data is in progress --> <!-- Text for the snackbar to show the user that the deletion of browsing data is in progress -->
<string name="deleting_browsing_data_in_progress">Deleting browsing data&#8230;</string> <string name="deleting_browsing_data_in_progress">Deleting browsing data&#8230;</string>
<!-- Dialog message to the user asking to delete all history items inside the opened group. -->
<string name="delete_history_group_prompt_message">This will delete all items.</string>
<!-- Text for the cancel button for the history group deletion dialog -->
<string name="delete_history_group_prompt_cancel">Cancel</string>
<!-- Text for the allow button for the history group dialog -->
<string name="delete_history_group_prompt_allow">Delete</string>
<!-- Text for the snackbar confirmation that the history group was deleted -->
<string name="delete_history_group_snackbar">Group deleted</string>
<!-- Tips --> <!-- Tips -->
<!-- text for firefox preview moving tip header "Firefox Preview" and "Firefox Nightly" are intentionally hardcoded --> <!-- text for firefox preview moving tip header "Firefox Preview" and "Firefox Nightly" are intentionally hardcoded -->
<string name="tip_firefox_preview_moved_header" moz:RemovedIn="100" tools:ignore="UnusedResources">Firefox Preview is now Firefox Nightly</string> <string name="tip_firefox_preview_moved_header" moz:RemovedIn="100" tools:ignore="UnusedResources">Firefox Preview is now Firefox Nightly</string>

@ -405,18 +405,6 @@ class BookmarkControllerTest {
} }
} }
@Test
fun `WHEN onSearch is called with BookmarkFragment THEN navigate to BookmarkSearchDialogFragment`() {
val controller = createController()
controller.handleSearch()
verify {
navController.navigate(
BookmarkFragmentDirections.actionBookmarkFragmentToBookmarkSearchDialogFragment()
)
}
}
@Suppress("LongParameterList") @Suppress("LongParameterList")
private fun createController( private fun createController(
loadBookmarkNode: suspend (String) -> BookmarkNode? = { _ -> null }, loadBookmarkNode: suspend (String) -> BookmarkNode? = { _ -> null },

@ -18,18 +18,28 @@ import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.history.DefaultPagedHistoryProvider
import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.ext.navigateSafe import org.mozilla.fenix.ext.navigateSafe
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
@RunWith(FenixRobolectricTestRunner::class) @RunWith(FenixRobolectricTestRunner::class)
class HistoryControllerTest { class HistoryControllerTest {
private val historyItem = History.Regular(0, "title", "url", 0.toLong(), HistoryItemTimeGroup.timeGroupForTimestamp(0)) private val historyItem = History.Regular(
0,
"title",
"url",
0.toLong(),
HistoryItemTimeGroup.timeGroupForTimestamp(0)
)
private val scope = TestCoroutineScope() private val scope = TestCoroutineScope()
private val store: HistoryFragmentStore = mockk(relaxed = true) private val store: HistoryFragmentStore = mockk(relaxed = true)
private val appStore: AppStore = mockk(relaxed = true)
private val state: HistoryFragmentState = mockk(relaxed = true) private val state: HistoryFragmentState = mockk(relaxed = true)
private val navController: NavController = mockk(relaxed = true) private val navController: NavController = mockk(relaxed = true)
private val metrics: MetricController = mockk(relaxed = true) private val metrics: MetricController = mockk(relaxed = true)
private val historyProvider: DefaultPagedHistoryProvider = mockk(relaxed = true)
@Before @Before
fun setUp() { fun setUp() {
@ -183,12 +193,14 @@ class HistoryControllerTest {
): HistoryController { ): HistoryController {
return DefaultHistoryController( return DefaultHistoryController(
store, store,
appStore,
historyProvider,
navController, navController,
scope, scope,
openInBrowser, openInBrowser,
displayDeleteAll, displayDeleteAll,
invalidateOptionsMenu, invalidateOptionsMenu,
deleteHistoryItems, { items, _, _ -> deleteHistoryItems.invoke(items) },
syncHistory, syncHistory,
metrics metrics
) )

@ -6,12 +6,15 @@ package org.mozilla.fenix.library.history
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotSame import org.junit.Assert.assertNotSame
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class HistoryFragmentStoreTest { class HistoryFragmentStoreTest {
private val historyItem = History.Regular(0, "title", "url", 0.toLong(), HistoryItemTimeGroup.timeGroupForTimestamp(0)) private val historyItem = History.Regular(0, "title", "url", 0.toLong(), HistoryItemTimeGroup.timeGroupForTimestamp(0))
private val newHistoryItem = History.Regular(1, "title", "url", 0.toLong(), HistoryItemTimeGroup.timeGroupForTimestamp(0)) private val newHistoryItem = History.Regular(1, "title", "url", 0.toLong(), HistoryItemTimeGroup.timeGroupForTimestamp(0))
private val pendingDeletionItem = historyItem.toPendingDeletionHistory()
@Test @Test
fun exitEditMode() = runBlocking { fun exitEditMode() = runBlocking {
@ -61,7 +64,8 @@ class HistoryFragmentStoreTest {
val initialState = HistoryFragmentState( val initialState = HistoryFragmentState(
items = listOf(), items = listOf(),
mode = HistoryFragmentState.Mode.Syncing, mode = HistoryFragmentState.Mode.Syncing,
pendingDeletionIds = emptySet(), pendingDeletionItems = emptySet(),
isEmpty = false,
isDeletingItems = false isDeletingItems = false
) )
val store = HistoryFragmentStore(initialState) val store = HistoryFragmentStore(initialState)
@ -71,24 +75,55 @@ class HistoryFragmentStoreTest {
assertEquals(HistoryFragmentState.Mode.Normal, store.state.mode) assertEquals(HistoryFragmentState.Mode.Normal, store.state.mode)
} }
@Test
fun changeEmptyState() = runBlocking {
val initialState = emptyDefaultState()
val store = HistoryFragmentStore(initialState)
store.dispatch(HistoryFragmentAction.ChangeEmptyState(true)).join()
assertNotSame(initialState, store.state)
assertTrue(store.state.isEmpty)
store.dispatch(HistoryFragmentAction.ChangeEmptyState(false)).join()
assertNotSame(initialState, store.state)
assertFalse(store.state.isEmpty)
}
@Test
fun updatePendingDeletionItems() = runBlocking {
val initialState = emptyDefaultState()
val store = HistoryFragmentStore(initialState)
store.dispatch(HistoryFragmentAction.UpdatePendingDeletionItems(setOf(pendingDeletionItem))).join()
assertNotSame(initialState, store.state)
assertEquals(setOf(pendingDeletionItem), store.state.pendingDeletionItems)
store.dispatch(HistoryFragmentAction.UpdatePendingDeletionItems(emptySet())).join()
assertNotSame(initialState, store.state)
assertEquals(emptySet<PendingDeletionHistory>(), store.state.pendingDeletionItems)
}
private fun emptyDefaultState(): HistoryFragmentState = HistoryFragmentState( private fun emptyDefaultState(): HistoryFragmentState = HistoryFragmentState(
items = listOf(), items = listOf(),
mode = HistoryFragmentState.Mode.Normal, mode = HistoryFragmentState.Mode.Normal,
pendingDeletionIds = emptySet(), pendingDeletionItems = emptySet(),
isEmpty = false,
isDeletingItems = false isDeletingItems = false
) )
private fun oneItemEditState(): HistoryFragmentState = HistoryFragmentState( private fun oneItemEditState(): HistoryFragmentState = HistoryFragmentState(
items = listOf(), items = listOf(),
mode = HistoryFragmentState.Mode.Editing(setOf(historyItem)), mode = HistoryFragmentState.Mode.Editing(setOf(historyItem)),
pendingDeletionIds = emptySet(), pendingDeletionItems = emptySet(),
isEmpty = false,
isDeletingItems = false isDeletingItems = false
) )
private fun twoItemEditState(): HistoryFragmentState = HistoryFragmentState( private fun twoItemEditState(): HistoryFragmentState = HistoryFragmentState(
items = listOf(), items = listOf(),
mode = HistoryFragmentState.Mode.Editing(setOf(historyItem, newHistoryItem)), mode = HistoryFragmentState.Mode.Editing(setOf(historyItem, newHistoryItem)),
pendingDeletionIds = emptySet(), pendingDeletionItems = emptySet(),
isEmpty = false,
isDeletingItems = false isDeletingItems = false
) )
} }

@ -13,6 +13,8 @@ import org.junit.Before
import org.junit.Test import org.junit.Test
import org.mozilla.fenix.library.history.History import org.mozilla.fenix.library.history.History
import org.mozilla.fenix.library.history.HistoryItemTimeGroup import org.mozilla.fenix.library.history.HistoryItemTimeGroup
import org.mozilla.fenix.library.history.PendingDeletionHistory
import org.mozilla.fenix.library.history.toPendingDeletionHistory
class HistoryMetadataGroupFragmentStoreTest { class HistoryMetadataGroupFragmentStoreTest {
@ -37,10 +39,15 @@ class HistoryMetadataGroupFragmentStoreTest {
totalViewTime = 0, totalViewTime = 0,
historyMetadataKey = HistoryMetadataKey("http://www.firefox.com", "mozilla", null) historyMetadataKey = HistoryMetadataKey("http://www.firefox.com", "mozilla", null)
) )
private val pendingDeletionItem = mozillaHistoryMetadataItem.toPendingDeletionHistory()
@Before @Before
fun setup() { fun setup() {
state = HistoryMetadataGroupFragmentState() state = HistoryMetadataGroupFragmentState(
items = emptyList(),
pendingDeletionItems = emptySet(),
isEmpty = true
)
store = HistoryMetadataGroupFragmentStore(state) store = HistoryMetadataGroupFragmentStore(state)
} }
@ -106,4 +113,22 @@ class HistoryMetadataGroupFragmentStoreTest {
assertEquals(0, store.state.items.size) assertEquals(0, store.state.items.size)
} }
@Test
fun `Test changing the empty state of HistoryMetadataGroupFragmentStore`() = runBlocking {
store.dispatch(HistoryMetadataGroupFragmentAction.ChangeEmptyState(false)).join()
assertFalse(store.state.isEmpty)
store.dispatch(HistoryMetadataGroupFragmentAction.ChangeEmptyState(true)).join()
assertTrue(store.state.isEmpty)
}
@Test
fun `Test updating pending deletion items in HistoryMetadataGroupFragmentStore`() = runBlocking {
store.dispatch(HistoryMetadataGroupFragmentAction.UpdatePendingDeletionItems(setOf(pendingDeletionItem))).join()
assertEquals(setOf(pendingDeletionItem), store.state.pendingDeletionItems)
store.dispatch(HistoryMetadataGroupFragmentAction.UpdatePendingDeletionItems(setOf())).join()
assertEquals(emptySet<PendingDeletionHistory>(), store.state.pendingDeletionItems)
}
} }

@ -4,11 +4,13 @@
package org.mozilla.fenix.library.historymetadata.controller package org.mozilla.fenix.library.historymetadata.controller
import android.content.Context
import androidx.navigation.NavController import androidx.navigation.NavController
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestCoroutineScope import kotlinx.coroutines.test.TestCoroutineScope
import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.runBlockingTest
import mozilla.components.browser.state.action.HistoryMetadataAction import mozilla.components.browser.state.action.HistoryMetadataAction
@ -31,6 +33,7 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.directionsEq import org.mozilla.fenix.ext.directionsEq
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
@ -52,6 +55,8 @@ class HistoryMetadataGroupControllerTest {
private val scope = TestCoroutineScope(testDispatcher) private val scope = TestCoroutineScope(testDispatcher)
private val activity: HomeActivity = mockk(relaxed = true) private val activity: HomeActivity = mockk(relaxed = true)
private val context: Context = mockk(relaxed = true)
private val appStore: AppStore = mockk(relaxed = true)
private val store: HistoryMetadataGroupFragmentStore = mockk(relaxed = true) private val store: HistoryMetadataGroupFragmentStore = mockk(relaxed = true)
private val browserStore: BrowserStore = mockk(relaxed = true) private val browserStore: BrowserStore = mockk(relaxed = true)
private val selectOrAddUseCase: TabsUseCases.SelectOrAddUseCase = mockk(relaxed = true) private val selectOrAddUseCase: TabsUseCases.SelectOrAddUseCase = mockk(relaxed = true)
@ -89,14 +94,23 @@ class HistoryMetadataGroupControllerTest {
controller = DefaultHistoryMetadataGroupController( controller = DefaultHistoryMetadataGroupController(
historyStorage = historyStorage, historyStorage = historyStorage,
browserStore = browserStore, browserStore = browserStore,
appStore = appStore,
store = store, store = store,
selectOrAddUseCase = selectOrAddUseCase, selectOrAddUseCase = selectOrAddUseCase,
navController = navController, navController = navController,
scope = scope, searchTerm = "mozilla",
searchTerm = "mozilla" deleteSnackbar = { items, _, delete ->
scope.launch {
delete(items).invoke(context)
}
},
promptDeleteAll = { deleteAll -> deleteAll.invoke() },
allDeletedSnackbar = {}
) )
every { activity.components.core.historyStorage } returns historyStorage every { activity.components.core.historyStorage } returns historyStorage
every { context.components.core.store } returns browserStore
every { context.components.core.historyStorage } returns historyStorage
every { store.state.items } returns getMetadataItemsList() every { store.state.items } returns getMetadataItemsList()
} }

@ -50,7 +50,7 @@ class HistoryMetadataGroupItemViewHolderTest {
@Test @Test
fun `GIVEN a history metadata item on bind THEN set the title and url text`() { fun `GIVEN a history metadata item on bind THEN set the title and url text`() {
every { testContext.components.core.icons } returns BrowserIcons(testContext, mockk(relaxed = true)) every { testContext.components.core.icons } returns BrowserIcons(testContext, mockk(relaxed = true))
HistoryMetadataGroupItemViewHolder(binding.root, interactor, selectionHolder).bind(item) HistoryMetadataGroupItemViewHolder(binding.root, interactor, selectionHolder).bind(item, isPendingDeletion = false)
assertEquals(item.title, binding.historyLayout.titleView.text) assertEquals(item.title, binding.historyLayout.titleView.text)
assertEquals(item.url, binding.historyLayout.urlView.text) assertEquals(item.url, binding.historyLayout.urlView.text)

Loading…
Cancel
Save