Skip to content

Commit

Permalink
Search for bookmarks and folders by title (#2936)
Browse files Browse the repository at this point in the history
  • Loading branch information
jotaemepereira authored Jul 4, 2024
1 parent ce9dca1 commit 7577a09
Show file tree
Hide file tree
Showing 7 changed files with 223 additions and 0 deletions.
12 changes: 12 additions & 0 deletions DuckDuckGo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -4123,6 +4127,8 @@
B6F9BDE32B45CD1900677B33 /* ModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalView.swift; sourceTree = "<group>"; };
B6FA893E269C424500588ECD /* PrivacyDashboardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDashboardViewController.swift; sourceTree = "<group>"; };
B6FA8940269C425400588ECD /* PrivacyDashboardPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDashboardPopover.swift; sourceTree = "<group>"; };
BB1597BA2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchViewModel.swift; sourceTree = "<group>"; };
BB1597BD2C35B60A001FB9B5 /* BookmarkSearchViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkSearchViewModelTests.swift; sourceTree = "<group>"; };
BB5789712B2CA70F0009DFE2 /* DataBrokerProtectionSubscriptionEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionSubscriptionEventHandler.swift; sourceTree = "<group>"; };
BBDFDC592B2B8A0900F62D90 /* DataBrokerProtectionExternalWaitlistPixels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionExternalWaitlistPixels.swift; sourceTree = "<group>"; };
BD384AC72BBC821100EF3735 /* vpn-light-mode.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "vpn-light-mode.json"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -6501,6 +6507,7 @@
9F26060D2B85E17D00819292 /* AddEditBookmarkDialogCoordinatorViewModelTests.swift */,
9F0FFFB32BCCAE37007C87DD /* BookmarkAllTabsDialogCoordinatorViewModelTests.swift */,
9FA5A0A82BC900FC00153786 /* BookmarkAllTabsDialogViewModelTests.swift */,
BB1597BD2C35B60A001FB9B5 /* BookmarkSearchViewModelTests.swift */,
);
path = ViewModels;
sourceTree = "<group>";
Expand Down Expand Up @@ -7322,6 +7329,7 @@
9FEE986C2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift */,
9F9C4A002BC7F36D0099738D /* BookmarkAllTabsDialogCoordinatorViewModel.swift */,
9F9C49FC2BC7E9820099738D /* BookmarkAllTabsDialogViewModel.swift */,
BB1597BA2C35AF00001FB9B5 /* BookmarkSearchViewModel.swift */,
);
path = ViewModel;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
36 changes: 36 additions & 0 deletions DuckDuckGo/Bookmarks/Model/BookmarkManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<BookmarkList?>.Publisher { get }
var list: BookmarkList? { get }
Expand Down Expand Up @@ -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
}
}
41 changes: 41 additions & 0 deletions DuckDuckGo/Bookmarks/ViewModel/BookmarkSearchViewModel.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions DuckDuckGo/Common/Extensions/StringExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ extension String {
data(using: .utf8)!
}

var isBlank: Bool {
self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}

// MARK: - URL

var url: URL? {
Expand Down
70 changes: 70 additions & 0 deletions UnitTests/Bookmarks/Model/LocalBookmarkManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
54 changes: 54 additions & 0 deletions UnitTests/Bookmarks/ViewModels/BookmarkSearchViewModelTests.swift
Original file line number Diff line number Diff line change
@@ -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]))
}
}
6 changes: 6 additions & 0 deletions UnitTests/HomePage/Mocks/MockBookmarkManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import Foundation

class MockBookmarkManager: BookmarkManager {

var bookmarksReturnedForSearch = [BaseBookmarkEntity]()

func isUrlFavorited(url: URL) -> Bool {
return false
}
Expand Down Expand Up @@ -103,4 +105,8 @@ class MockBookmarkManager: BookmarkManager {
func requestSync() {
}

func search(by query: String) -> [BaseBookmarkEntity] {
return bookmarksReturnedForSearch
}

}

0 comments on commit 7577a09

Please sign in to comment.