From 7577a09a15950c298f4a3ec1461657a2af700707 Mon Sep 17 00:00:00 2001 From: Juan Manuel Pereira Date: Thu, 4 Jul 2024 09:54:26 -0300 Subject: [PATCH] Search for bookmarks and folders by title (#2936) --- DuckDuckGo.xcodeproj/project.pbxproj | 12 ++++ .../Bookmarks/Model/BookmarkManager.swift | 36 ++++++++++ .../ViewModel/BookmarkSearchViewModel.swift | 41 +++++++++++ .../Common/Extensions/StringExtension.swift | 4 ++ .../Model/LocalBookmarkManagerTests.swift | 70 +++++++++++++++++++ .../BookmarkSearchViewModelTests.swift | 54 ++++++++++++++ .../HomePage/Mocks/MockBookmarkManager.swift | 6 ++ 7 files changed, 223 insertions(+) create mode 100644 DuckDuckGo/Bookmarks/ViewModel/BookmarkSearchViewModel.swift create mode 100644 UnitTests/Bookmarks/ViewModels/BookmarkSearchViewModelTests.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 7667b0252c..f9f73e8ff9 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -2485,6 +2485,10 @@ B6F9BDE52B45CD1900677B33 /* ModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F9BDE32B45CD1900677B33 /* ModalView.swift */; }; B6FA893F269C424500588ECD /* PrivacyDashboardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FA893E269C424500588ECD /* PrivacyDashboardViewController.swift */; }; B6FA8941269C425400588ECD /* PrivacyDashboardPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FA8940269C425400588ECD /* PrivacyDashboardPopover.swift */; }; + BB1597BB2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1597BA2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift */; }; + BB1597BC2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1597BA2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift */; }; + BB1597C02C35B666001FB9B5 /* BookmarkSearchViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1597BD2C35B60A001FB9B5 /* BookmarkSearchViewModelTests.swift */; }; + BB1597C12C35B667001FB9B5 /* BookmarkSearchViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1597BD2C35B60A001FB9B5 /* BookmarkSearchViewModelTests.swift */; }; BB5789722B2CA70F0009DFE2 /* DataBrokerProtectionSubscriptionEventHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB5789712B2CA70F0009DFE2 /* DataBrokerProtectionSubscriptionEventHandler.swift */; }; BBDFDC5A2B2B8A0900F62D90 /* DataBrokerProtectionExternalWaitlistPixels.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBDFDC592B2B8A0900F62D90 /* DataBrokerProtectionExternalWaitlistPixels.swift */; }; BBDFDC5D2B2B8E2100F62D90 /* DataBrokerProtectionExternalWaitlistPixels.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBDFDC592B2B8A0900F62D90 /* DataBrokerProtectionExternalWaitlistPixels.swift */; }; @@ -4123,6 +4127,8 @@ B6F9BDE32B45CD1900677B33 /* ModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalView.swift; sourceTree = ""; }; B6FA893E269C424500588ECD /* PrivacyDashboardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDashboardViewController.swift; sourceTree = ""; }; B6FA8940269C425400588ECD /* PrivacyDashboardPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDashboardPopover.swift; sourceTree = ""; }; + BB1597BA2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchViewModel.swift; sourceTree = ""; }; + BB1597BD2C35B60A001FB9B5 /* BookmarkSearchViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchViewModelTests.swift; sourceTree = ""; }; BB5789712B2CA70F0009DFE2 /* DataBrokerProtectionSubscriptionEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionSubscriptionEventHandler.swift; sourceTree = ""; }; BBDFDC592B2B8A0900F62D90 /* DataBrokerProtectionExternalWaitlistPixels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionExternalWaitlistPixels.swift; sourceTree = ""; }; BD384AC72BBC821100EF3735 /* vpn-light-mode.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "vpn-light-mode.json"; sourceTree = ""; }; @@ -6501,6 +6507,7 @@ 9F26060D2B85E17D00819292 /* AddEditBookmarkDialogCoordinatorViewModelTests.swift */, 9F0FFFB32BCCAE37007C87DD /* BookmarkAllTabsDialogCoordinatorViewModelTests.swift */, 9FA5A0A82BC900FC00153786 /* BookmarkAllTabsDialogViewModelTests.swift */, + BB1597BD2C35B60A001FB9B5 /* BookmarkSearchViewModelTests.swift */, ); path = ViewModels; sourceTree = ""; @@ -7322,6 +7329,7 @@ 9FEE986C2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift */, 9F9C4A002BC7F36D0099738D /* BookmarkAllTabsDialogCoordinatorViewModel.swift */, 9F9C49FC2BC7E9820099738D /* BookmarkAllTabsDialogViewModel.swift */, + BB1597BA2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -9895,6 +9903,7 @@ 3706FB35293F65D500E42796 /* FlatButton.swift in Sources */, 3706FB36293F65D500E42796 /* PinnedTabView.swift in Sources */, 3706FB37293F65D500E42796 /* DataEncryption.swift in Sources */, + BB1597BC2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift in Sources */, 56BA1E762BAAF70F001CF69F /* SSLErrorPageTabExtension.swift in Sources */, 4B9DB0362A983B24000927DB /* WaitlistTermsAndConditionsView.swift in Sources */, 37197EA82942443D00394917 /* BrowserTabViewController.swift in Sources */, @@ -10578,6 +10587,7 @@ 4BBEE8DE2BFEDE3E00E5E111 /* SurveyRemoteMessageTests.swift in Sources */, 562532A12BC069190034D316 /* ZoomPopoverViewModelTests.swift in Sources */, 3706FE28293F661700E42796 /* BookmarkTests.swift in Sources */, + BB1597C12C35B667001FB9B5 /* BookmarkSearchViewModelTests.swift in Sources */, 3706FE29293F661700E42796 /* SuggestionContainerViewModelTests.swift in Sources */, 1D8C2FEB2B70F5A7005E4BBD /* MockWebViewSnapshotRenderer.swift in Sources */, 56A0540E2C1C375E007D8FAB /* MockWindow.swift in Sources */, @@ -11183,6 +11193,7 @@ B6676BE12AA986A700525A21 /* AddressBarTextEditor.swift in Sources */, B69B503B2726A12500758A2B /* Atb.swift in Sources */, 37A6A8F12AFCC988008580A3 /* FaviconsFetcherOnboarding.swift in Sources */, + BB1597BB2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift in Sources */, 7BEC20452B0F505F00243D3E /* AddBookmarkFolderPopoverView.swift in Sources */, B6C0BB6A29AF1C7000AE8E3C /* BrowserTabView.swift in Sources */, B6B1E88026D5DA9B0062C350 /* DownloadsViewController.swift in Sources */, @@ -12156,6 +12167,7 @@ 317295D22AF058D3002C3206 /* MockWaitlistTermsAndConditionsActionHandler.swift in Sources */, B6C843DA2BA1CAB6006FDEC3 /* FilePresenterTests.swift in Sources */, B693956326F1C2A40015B914 /* FileDownloadManagerMock.swift in Sources */, + BB1597C02C35B666001FB9B5 /* BookmarkSearchViewModelTests.swift in Sources */, B6C2C9EF276081AB005B7F0A /* DeallocationTests.swift in Sources */, B63ED0D826AE729600A9DAD1 /* PermissionModelTests.swift in Sources */, B69B504B2726CA2900758A2B /* MockStatisticsStore.swift in Sources */, diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift b/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift index 1220f5eabb..cbbff0ca40 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkManager.swift @@ -50,6 +50,13 @@ protocol BookmarkManager: AnyObject { func importBookmarks(_ bookmarks: ImportedBookmarks, source: BookmarkImportSource) -> BookmarksImportSummary func handleFavoritesAfterDisablingSync() + /// Searches for bookmarks and folders by title. If query is blank empty list is returned + /// + /// - Parameters: + /// - query: The query we will use to filter bookmarks. We will check if query is contained in the title. + /// - Returns: An array of bookmarks that matches the query. + func search(by query: String) -> [BaseBookmarkEntity] + // Wrapper definition in a protocol is not supported yet var listPublisher: Published.Publisher { get } var list: BookmarkList? { get } @@ -388,4 +395,33 @@ final class LocalBookmarkManager: BookmarkManager { } } + + // MARK: - Search + + func search(by query: String) -> [BaseBookmarkEntity] { + guard let topLevelEntities = list?.topLevelEntities, !query.isBlank else { + return [BaseBookmarkEntity]() + } + + return search(query: query, in: topLevelEntities) + } + + private func search(query: String, in bookmarks: [BaseBookmarkEntity]) -> [BaseBookmarkEntity] { + var result: [BaseBookmarkEntity] = [] + + var queue: [BaseBookmarkEntity] = bookmarks + while !queue.isEmpty { + let current = queue.removeFirst() + + if current.title.lowercased().contains(query) { + result.append(current) + } + + if let folder = current as? BookmarkFolder { + queue.append(contentsOf: folder.children) + } + } + + return result + } } diff --git a/DuckDuckGo/Bookmarks/ViewModel/BookmarkSearchViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/BookmarkSearchViewModel.swift new file mode 100644 index 0000000000..4856df7ea8 --- /dev/null +++ b/DuckDuckGo/Bookmarks/ViewModel/BookmarkSearchViewModel.swift @@ -0,0 +1,41 @@ +// +// BookmarkSearchViewModel.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct BookmarkSearchViewModel { + + enum BookmarkSearchResult: Equatable { + case emptyQuery + case results([BaseBookmarkEntity]) + + static let noResults = Self.results([]) + } + + let manager: BookmarkManager + + func search(by query: String) -> BookmarkSearchResult { + if query.isBlank { + return .emptyQuery + } + + let filteredBookmarks = manager.search(by: query) + + return filteredBookmarks.isEmpty ? .noResults : .results(filteredBookmarks) + } +} diff --git a/DuckDuckGo/Common/Extensions/StringExtension.swift b/DuckDuckGo/Common/Extensions/StringExtension.swift index 6128d9906a..043633f338 100644 --- a/DuckDuckGo/Common/Extensions/StringExtension.swift +++ b/DuckDuckGo/Common/Extensions/StringExtension.swift @@ -85,6 +85,10 @@ extension String { data(using: .utf8)! } + var isBlank: Bool { + self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + // MARK: - URL var url: URL? { diff --git a/UnitTests/Bookmarks/Model/LocalBookmarkManagerTests.swift b/UnitTests/Bookmarks/Model/LocalBookmarkManagerTests.swift index 79d3809847..bf9b7d0d17 100644 --- a/UnitTests/Bookmarks/Model/LocalBookmarkManagerTests.swift +++ b/UnitTests/Bookmarks/Model/LocalBookmarkManagerTests.swift @@ -259,6 +259,76 @@ final class LocalBookmarkManagerTests: XCTestCase { XCTAssertTrue(bookmarkStoreMock.loadAllCalled) } + // MARK: - Search + + func testWhenBookmarkListIsNilThenSearchIsEmpty() { + let sut = LocalBookmarkManager() + let results = sut.search(by: "abc") + + XCTAssertNil(sut.list) + XCTAssertTrue(results.isEmpty) + } + + func testWhenQueryIsEmptyThenSearchResultsAreEmpty() { + let bookmarkStore = BookmarkStoreMock(bookmarks: topLevelBookmarks()) + let sut = LocalBookmarkManager(bookmarkStore: bookmarkStore, faviconManagement: FaviconManagerMock()) + + sut.loadBookmarks() + + let results = sut.search(by: "") + + XCTAssertTrue(results.isEmpty) + } + + func testWhenQueryIsBlankThenSearchResultsAreEmpty() { + let bookmarkStore = BookmarkStoreMock(bookmarks: topLevelBookmarks()) + let sut = LocalBookmarkManager(bookmarkStore: bookmarkStore, faviconManagement: FaviconManagerMock()) + + sut.loadBookmarks() + + let results = sut.search(by: " ") + + XCTAssertTrue(results.isEmpty) + } + + func testWhenASearchIsDoneThenCorrectResultsAreReturnedAndIntheRightOrder() { + let bookmarkStore = BookmarkStoreMock(bookmarks: topLevelBookmarks()) + let sut = LocalBookmarkManager(bookmarkStore: bookmarkStore, faviconManagement: FaviconManagerMock()) + + sut.loadBookmarks() + + let results = sut.search(by: "folder") + + XCTAssertTrue(results.count == 3) + XCTAssertEqual(results[0].title, "This is a folder") + XCTAssertEqual(results[1].title, "Favorite folder") + XCTAssertEqual(results[2].title, "This is a sub-folder") + } + + func testWhenASearchIsDoneThenFoldersAndBookmarksAreReturned() { + let bookmarkStore = BookmarkStoreMock(bookmarks: topLevelBookmarks()) + let sut = LocalBookmarkManager(bookmarkStore: bookmarkStore, faviconManagement: FaviconManagerMock()) + + sut.loadBookmarks() + + let results = sut.search(by: "favorite") + + XCTAssertTrue(results.count == 2) + XCTAssertEqual(results[0].title, "Favorite folder") + XCTAssertTrue(results[0].isFolder) + XCTAssertEqual(results[1].title, "Favorite bookmark") + XCTAssertFalse(results[1].isFolder) + } + + private func topLevelBookmarks() -> [BaseBookmarkEntity] { + let topBookmark = Bookmark(id: "4", url: "www.favorite.com", title: "Favorite bookmark", isFavorite: true) + let favoriteFolder = BookmarkFolder(id: "5", title: "Favorite folder", children: [topBookmark]) + let bookmark = Bookmark(id: "3", url: "www.ddg.com", title: "This is a bookmark", isFavorite: false) + let subFolder = BookmarkFolder(id: "1", title: "This is a sub-folder", children: [bookmark]) + let parent = BookmarkFolder(id: "2", title: "This is a folder", children: [subFolder]) + + return [parent, favoriteFolder] + } } fileprivate extension LocalBookmarkManager { diff --git a/UnitTests/Bookmarks/ViewModels/BookmarkSearchViewModelTests.swift b/UnitTests/Bookmarks/ViewModels/BookmarkSearchViewModelTests.swift new file mode 100644 index 0000000000..381369a428 --- /dev/null +++ b/UnitTests/Bookmarks/ViewModels/BookmarkSearchViewModelTests.swift @@ -0,0 +1,54 @@ +// +// BookmarkSearchViewModelTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class BookmarkSearchViewModelTests: XCTestCase { + + func testWhenQueryIsEmpty_thenEmptyQueryIsReturned() { + let sut = BookmarkSearchViewModel(manager: MockBookmarkManager()) + let result = sut.search(by: "") + + XCTAssertEqual(result, .emptyQuery) + } + + func testWhenQueryIsBlank_thenEmptyQueryIsReturned() { + let sut = BookmarkSearchViewModel(manager: MockBookmarkManager()) + let result = sut.search(by: " ") + + XCTAssertEqual(result, .emptyQuery) + } + + func testWhenQueryIsNotBlankAndQueryDoesNotMatchBookmarks_thenNoResultsIsReturned() { + let sut = BookmarkSearchViewModel(manager: MockBookmarkManager()) + let result = sut.search(by: "abc") + + XCTAssertEqual(result, .noResults) + } + + func testWhenQueryIsNotBlankAndQueryMatchesBookmarks_thenResultsAreReturned() { + let bookmark = Bookmark(id: "1", url: "www.test.com", title: "Bookmark", isFavorite: false) + let manager = MockBookmarkManager() + manager.bookmarksReturnedForSearch = [bookmark] + let sut = BookmarkSearchViewModel(manager: manager) + let result = sut.search(by: "abc") + + XCTAssertEqual(result, .results([bookmark])) + } +} diff --git a/UnitTests/HomePage/Mocks/MockBookmarkManager.swift b/UnitTests/HomePage/Mocks/MockBookmarkManager.swift index cf0c87be4d..dad3092099 100644 --- a/UnitTests/HomePage/Mocks/MockBookmarkManager.swift +++ b/UnitTests/HomePage/Mocks/MockBookmarkManager.swift @@ -21,6 +21,8 @@ import Foundation class MockBookmarkManager: BookmarkManager { + var bookmarksReturnedForSearch = [BaseBookmarkEntity]() + func isUrlFavorited(url: URL) -> Bool { return false } @@ -103,4 +105,8 @@ class MockBookmarkManager: BookmarkManager { func requestSync() { } + func search(by query: String) -> [BaseBookmarkEntity] { + return bookmarksReturnedForSearch + } + }