commit 6e9ddfa043b7f7535ad03f5dd43be65de8c8ce39 parent 199af893683795b60093a321bcb91b92b59fd4c6 Author: Akhil Pindiprolu <apindiprolu@mozilla.com> Date: Wed, 17 Dec 2025 19:13:51 +0000 Bug 1994282 - [Tab Management Search] Create the Redux layer for Tab Search r=android-reviewers,calu,007 Differential Revision: https://phabricator.services.mozilla.com/D275110 Diffstat:
7 files changed, 314 insertions(+), 29 deletions(-)
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayStore.kt @@ -11,6 +11,8 @@ import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.State import mozilla.components.lib.state.Store import org.mozilla.fenix.tabstray.navigation.TabManagerNavDestination +import org.mozilla.fenix.tabstray.redux.reducer.TabSearchActionReducer +import org.mozilla.fenix.tabstray.redux.state.TabSearchState import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsListItem private const val DEFAULT_SYNCED_TABS_EXPANDED_STATE = true @@ -29,6 +31,7 @@ private const val DEFAULT_SYNCED_TABS_EXPANDED_STATE = true * @property syncedTabs The list of synced tabs. * @property syncing Whether the Synced Tabs feature should fetch the latest tabs from paired devices. * @property selectedTabId The ID of the currently selected (active) tab. + * @property tabSearchState The state of the tab search feature. * @property tabSearchEnabled Whether the tab search feature is enabled. * @property backStack The navigation history of the Tab Manager feature. * @property expandedSyncedTabs The list of expansion states for the syncedTabs. @@ -43,6 +46,7 @@ data class TabsTrayState( val syncedTabs: List<SyncedTabsListItem> = emptyList(), val syncing: Boolean = false, val selectedTabId: String? = null, + val tabSearchState: TabSearchState = TabSearchState(), val tabSearchEnabled: Boolean = false, val backStack: List<TabManagerNavDestination> = listOf(TabManagerNavDestination.Root), val expandedSyncedTabs: List<Boolean> = emptyList(), @@ -161,129 +165,159 @@ enum class Page { /** * [Action] implementation related to [TabsTrayStore]. */ -sealed class TabsTrayAction : Action { +sealed interface TabsTrayAction : Action { /** * Entered multi-select mode. */ - object EnterSelectMode : TabsTrayAction() + object EnterSelectMode : TabsTrayAction /** * Exited multi-select mode. */ - object ExitSelectMode : TabsTrayAction() + object ExitSelectMode : TabsTrayAction /** * Added a new [TabSessionState] to the selection set. */ - data class AddSelectTab(val tab: TabSessionState) : TabsTrayAction() + data class AddSelectTab(val tab: TabSessionState) : TabsTrayAction /** * Removed a [TabSessionState] from the selection set. */ - data class RemoveSelectTab(val tab: TabSessionState) : TabsTrayAction() + data class RemoveSelectTab(val tab: TabSessionState) : TabsTrayAction /** * The active page in the tray that is now in focus. */ - data class PageSelected(val page: Page) : TabsTrayAction() + data class PageSelected(val page: Page) : TabsTrayAction /** * A request to perform a "sync" action. */ - object SyncNow : TabsTrayAction() + object SyncNow : TabsTrayAction /** * When a "sync" action has completed; this can be triggered immediately after [SyncNow] if * no sync action was able to be performed. */ - object SyncCompleted : TabsTrayAction() + object SyncCompleted : TabsTrayAction /** * Updates the [TabsTrayState.inactiveTabsExpanded] boolean * * @property expanded The updated boolean to [TabsTrayState.inactiveTabsExpanded] */ - data class UpdateInactiveExpanded(val expanded: Boolean) : TabsTrayAction() + data class UpdateInactiveExpanded(val expanded: Boolean) : TabsTrayAction /** * Updates the list of tabs in [TabsTrayState.inactiveTabs]. */ - data class UpdateInactiveTabs(val tabs: List<TabSessionState>) : TabsTrayAction() + data class UpdateInactiveTabs(val tabs: List<TabSessionState>) : TabsTrayAction /** * Updates the list of tabs in [TabsTrayState.normalTabs]. */ - data class UpdateNormalTabs(val tabs: List<TabSessionState>) : TabsTrayAction() + data class UpdateNormalTabs(val tabs: List<TabSessionState>) : TabsTrayAction /** * Updates the list of tabs in [TabsTrayState.privateTabs]. */ - data class UpdatePrivateTabs(val tabs: List<TabSessionState>) : TabsTrayAction() + data class UpdatePrivateTabs(val tabs: List<TabSessionState>) : TabsTrayAction /** * Updates the list of synced tabs in [TabsTrayState.syncedTabs]. */ - data class UpdateSyncedTabs(val tabs: List<SyncedTabsListItem>) : TabsTrayAction() + data class UpdateSyncedTabs(val tabs: List<SyncedTabsListItem>) : TabsTrayAction /** * Updates the selected tab id. * * @property tabId The ID of the tab that is currently selected. */ - data class UpdateSelectedTabId(val tabId: String?) : TabsTrayAction() + data class UpdateSelectedTabId(val tabId: String?) : TabsTrayAction /** * Expands or collapses the header on the synced tabs page. * * @property index The index of the header. */ - data class SyncedTabsHeaderToggled(val index: Int) : TabsTrayAction() + data class SyncedTabsHeaderToggled(val index: Int) : TabsTrayAction /** * [TabsTrayAction] fired when the tab auto close dialog is shown. */ - object TabAutoCloseDialogShown : TabsTrayAction() + object TabAutoCloseDialogShown : TabsTrayAction /** * [TabsTrayAction] fired when the user requests to share all of their normal tabs. */ - object ShareAllNormalTabs : TabsTrayAction() + object ShareAllNormalTabs : TabsTrayAction /** * [TabsTrayAction] fired when the user requests to share all of their private tabs. */ - object ShareAllPrivateTabs : TabsTrayAction() + object ShareAllPrivateTabs : TabsTrayAction /** * [TabsTrayAction] fired when the user requests to close all normal tabs. */ - object CloseAllNormalTabs : TabsTrayAction() + object CloseAllNormalTabs : TabsTrayAction /** * [TabsTrayAction] fired when the user requests to close all private tabs. */ - object CloseAllPrivateTabs : TabsTrayAction() + object CloseAllPrivateTabs : TabsTrayAction /** * [TabsTrayAction] fired when the three-dot menu is displayed to the user. */ - object ThreeDotMenuShown : TabsTrayAction() + object ThreeDotMenuShown : TabsTrayAction /** * [TabsTrayAction] fired when the user requests to bookmark selected tabs. */ - data class BookmarkSelectedTabs(val tabCount: Int) : TabsTrayAction() + data class BookmarkSelectedTabs(val tabCount: Int) : TabsTrayAction /** * [TabsTrayAction] fired when the user clicks on the Tab Search icon. */ - object TabSearchClicked : TabsTrayAction() + object TabSearchClicked : TabsTrayAction /** * [TabsTrayAction] fired when the user clicks on the back button or swipes to navigate back. */ - object NavigateBackInvoked : TabsTrayAction() + object NavigateBackInvoked : TabsTrayAction +} + +/** + *[TabsTrayAction]'s that represent user interactions and [TabSearchState] updates for the + * Tab Search feature. + */ +sealed interface TabSearchAction : TabsTrayAction { + + /** + * Updates the search query. + * + * @property query The query of tab search the user has typed in. + */ + data class SearchQueryChanged(val query: String) : TabSearchAction + + /** + * When the list of matching open tabs has been computed for the current [SearchQueryChanged] action. + * + * @property results The complete list of open tabs that match the current query. + */ + data class SearchResultsUpdated( + val results: List<TabSessionState>, + ) : TabSearchAction + + /** + * Fired when the user taps on a search result for an open tab. + * + * @property tab The tab selected by the user. + */ + data class SearchResultClicked(val tab: TabSessionState) : TabSearchAction } /** @@ -333,6 +367,10 @@ internal object TabsTrayReducer { is TabsTrayAction.BookmarkSelectedTabs -> state is TabsTrayAction.ThreeDotMenuShown -> state + is TabSearchAction -> TabSearchActionReducer.reduce( + state = state, + action = action, + ) is TabsTrayAction.TabSearchClicked -> { state.copy(backStack = state.backStack + TabManagerNavDestination.TabSearch) } @@ -340,7 +378,16 @@ internal object TabsTrayReducer { is TabsTrayAction.NavigateBackInvoked -> { when { state.mode is TabsTrayState.Mode.Select -> state.copy(mode = TabsTrayState.Mode.Normal) - else -> state.copy(backStack = state.backStack.dropLast(1)) + + state.backStack.lastOrNull() == TabManagerNavDestination.TabSearch -> state.copy( + tabSearchState = TabSearchState( + query = "", + searchResults = emptyList(), + ), + backStack = state.popBackStack(), + ) + + else -> state.copy(backStack = state.popBackStack()) } } is TabsTrayAction.SyncedTabsHeaderToggled -> handleSyncedTabHeaderToggle(state, action) @@ -403,6 +450,12 @@ private fun handleSyncedTabHeaderToggle( } /** + * Drops the last entry of the [TabsTray] backstack. + */ +private fun TabsTrayState.popBackStack() = + backStack.dropLast(1) + +/** * A [Store] that holds the [TabsTrayState] for the tabs tray and reduces [TabsTrayAction]s * dispatched to the store. */ diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/redux/reducer/TabSearchActionReducer.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/redux/reducer/TabSearchActionReducer.kt @@ -0,0 +1,48 @@ +/* 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.tabstray.redux.reducer + +import org.mozilla.fenix.tabstray.TabSearchAction +import org.mozilla.fenix.tabstray.TabsTrayState + +/** + * Reducer for [TabSearchAction] dispatched from the Tabs Tray store. + */ +object TabSearchActionReducer { + + /** + * Reduces [TabSearchAction] into a new [TabsTrayState]. + * + * @param state The current [TabsTrayState]. + * @param action The [TabSearchAction] to reduce. + */ + fun reduce( + state: TabsTrayState, + action: TabSearchAction, + ): TabsTrayState { + return when (action) { + is TabSearchAction.SearchQueryChanged -> { + state.copy( + tabSearchState = state.tabSearchState.copy( + query = action.query, + searchResults = state.tabSearchState.searchResults, + ), + ) + } + + is TabSearchAction.SearchResultsUpdated -> { + state.copy( + tabSearchState = state.tabSearchState.copy( + searchResults = action.results, + ), + ) + } + + is TabSearchAction.SearchResultClicked -> { + state + } + } + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/redux/state/TabSearchState.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/redux/state/TabSearchState.kt @@ -0,0 +1,18 @@ +/* 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.tabstray.redux.state + +import mozilla.components.browser.state.state.TabSessionState + +/** + * Value type that represents the state of the Tab Search feature. + * + * @property query The text in the search field. + * @property searchResults The list of open tabs that match the current [query]. + */ +data class TabSearchState( + val query: String = "", + val searchResults: List<TabSessionState> = emptyList(), +) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/tabsearch/TabSearchScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/tabstray/ui/tabsearch/TabSearchScreen.kt @@ -27,7 +27,9 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider import mozilla.components.compose.base.searchbar.TopSearchBar +import mozilla.components.lib.state.ext.observeAsState import org.mozilla.fenix.R +import org.mozilla.fenix.tabstray.TabSearchAction import org.mozilla.fenix.tabstray.TabsTrayAction import org.mozilla.fenix.tabstray.TabsTrayState import org.mozilla.fenix.tabstray.TabsTrayStore @@ -44,8 +46,8 @@ import mozilla.components.ui.icons.R as iconsR fun TabSearchScreen( store: TabsTrayStore, ) { + val state by store.observeAsState(store.state) { it } val searchBarState = rememberSearchBarState() - var query by remember { mutableStateOf("") } var expanded by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { @@ -56,9 +58,9 @@ fun TabSearchScreen( TopSearchBar( state = searchBarState, modifier = Modifier.focusRequester(focusRequester), - query = query, - onQueryChange = { query = it }, - onSearch = { submitted -> query = submitted }, + query = state.tabSearchState.query, + onQueryChange = { store.dispatch(TabSearchAction.SearchQueryChanged(it)) }, + onSearch = { submitted -> store.dispatch(TabSearchAction.SearchQueryChanged(submitted)) }, expanded = expanded, onExpandedChange = { expanded = it }, placeholder = { diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStoreReducerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStoreReducerTest.kt @@ -10,6 +10,8 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import org.mozilla.fenix.tabstray.navigation.TabManagerNavDestination +import org.mozilla.fenix.tabstray.redux.reducer.TabSearchActionReducer +import org.mozilla.fenix.tabstray.redux.state.TabSearchState import org.mozilla.fenix.tabstray.syncedtabs.getFakeSyncedTabList class TabsTrayStoreReducerTest { @@ -183,4 +185,33 @@ class TabsTrayStoreReducerTest { assertTrue(resultState.expandedSyncedTabs[0]) } + + @Test + fun `WHEN the user leaves search THEN tab search state is reset to defaults`() { + val initialTab = createTab("https://mozilla.org") + + val initialState = TabsTrayState( + tabSearchState = TabSearchState( + query = "mozilla", + searchResults = listOf(initialTab), + ), + ) + + val inSearchState = TabsTrayReducer.reduce( + state = initialState, + action = TabsTrayAction.TabSearchClicked, + ) + + val resultState = TabsTrayReducer.reduce( + state = inSearchState, + action = TabsTrayAction.NavigateBackInvoked, + ) + + val expectedState = inSearchState.copy( + tabSearchState = TabSearchState(), + backStack = listOf(TabManagerNavDestination.Root), + ) + + assertEquals(expectedState, resultState) + } } diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/redux/reducer/TabSearchReducerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/redux/reducer/TabSearchReducerTest.kt @@ -0,0 +1,110 @@ +/* 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.tabstray.redux.reducer + +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.state.createTab +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mozilla.fenix.tabstray.TabSearchAction +import org.mozilla.fenix.tabstray.TabsTrayState +import org.mozilla.fenix.tabstray.redux.state.TabSearchState + +class TabSearchReducerTest { + + @Test + fun `WHEN SearchQueryChanged THEN tab search query is updated`() { + val tabs = listOf(createTab("https://example.com"), createTab("https://mozilla.org")) + + val initialState = TabsTrayState( + tabSearchState = TabSearchState( + query = "old query", + searchResults = tabs, + ), + ) + + val resultState = TabSearchActionReducer.reduce( + state = initialState, + action = TabSearchAction.SearchQueryChanged("new query"), + ) + + val expectedState = initialState.copy( + tabSearchState = initialState.tabSearchState.copy( + query = "new query", + searchResults = tabs, + ), + ) + + assertEquals(expectedState.tabSearchState.query, resultState.tabSearchState.query) + } + + @Test + fun `WHEN SearchResultsUpdated THEN query is cleared and results are updated`() { + val initialState = TabsTrayState( + tabSearchState = TabSearchState( + query = "mozilla", + searchResults = emptyList(), + ), + ) + + val firstTab = createTab("https://mozilla.org") + val secondTab = createTab("https://developer.mozilla.org") + val results = listOf(firstTab, secondTab) + + val resultState = TabSearchActionReducer.reduce( + state = initialState, + action = TabSearchAction.SearchResultsUpdated(results), + ) + + val expectedState = initialState.copy( + tabSearchState = initialState.tabSearchState.copy( + searchResults = results, + ), + ) + + assertEquals(expectedState, resultState) + } + + @Test + fun `WHEN search results are updated with empty list THEN the state reflects an empty results list`() { + val firstTab = createTab("https://mozilla.org") + val secondTab = createTab("https://developer.mozilla.org") + val results = listOf(firstTab, secondTab) + + val initialState = TabsTrayState( + tabSearchState = TabSearchState( + query = "mozilla", + searchResults = results, + ), + ) + + val emptyResults = emptyList<TabSessionState>() + + val actualResults = TabSearchActionReducer.reduce( + state = initialState, + action = TabSearchAction.SearchResultsUpdated(emptyResults), + ).tabSearchState.searchResults + + assertEquals(emptyResults, actualResults) + } + + @Test + fun `WHEN SearchResultClicked THEN state is unchanged`() { + val tab = createTab("https://mozilla.org") + val initialState = TabsTrayState( + tabSearchState = TabSearchState( + query = "mozilla", + searchResults = listOf(tab), + ), + ) + + val resultState = TabSearchActionReducer.reduce( + state = initialState, + action = TabSearchAction.SearchResultClicked(tab), + ) + + assertEquals(initialState, resultState) + } +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/redux/state/TabSearchStateTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/tabstray/redux/state/TabSearchStateTest.kt @@ -0,0 +1,23 @@ +/* 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.tabstray.redux.state + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TabSearchStateTest { + + @Test + fun `WHEN TabSearchState is created with defaults THEN values are empty and not loading`() { + val state = TabSearchState() + + assertEquals("", state.query) + assertTrue(state.searchResults.isEmpty()) + } +}