Skip to content

Commit

Permalink
Allow users to search items (#35)
Browse files Browse the repository at this point in the history
  • Loading branch information
KatherineInCode authored Apr 15, 2024
1 parent 673b748 commit a70ce1d
Show file tree
Hide file tree
Showing 16 changed files with 489 additions and 65 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ extension ItemListItem {
static func fixture(
id: String = "123",
name: String = "Name",
totp: ItemListTotpItem
totp: ItemListTotpItem = .fixture()
) -> ItemListItem {
ItemListItem(
id: id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ protocol AuthenticatorItemRepository: AnyObject {
/// - Returns: A publisher for the list of a user's items
///
func itemListPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[ItemListSection], Error>>

/// A publisher for searching a user's cipher objects based on the specified search text and filter type.
///
/// - Parameters:
/// - searchText: The search text to filter the cipher list.
/// - Returns: A publisher searching for the user's ciphers.
///
func searchItemListPublisher(
searchText: String
) async throws -> AsyncThrowingPublisher<AnyPublisher<[ItemListItem], Error>>
}

// MARK: - DefaultAuthenticatorItemRepository
Expand Down Expand Up @@ -115,6 +125,38 @@ class DefaultAuthenticatorItemRepository {
),
]
}

/// A publisher for searching a user's items based on the specified search text and filter type.
///
/// - Parameters:
/// - searchText: The search text to filter the item list.
/// - Returns: A publisher searching for the user's ciphers.
///
private func searchPublisher(
searchText: String
) async throws -> AnyPublisher<[AuthenticatorItemView], Error> {
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.folding(options: .diacriticInsensitive, locale: .current)

return try await authenticatorItemService.authenticatorItemsPublisher()
.asyncTryMap { items -> [AuthenticatorItemView] in
var matchedItems: [AuthenticatorItem] = []

items.forEach { item in
if item.name.lowercased()
.folding(options: .diacriticInsensitive, locale: nil)
.contains(query) {
matchedItems.append(item)
}
}

return try await matchedItems.asyncMap { item in
try await self.cryptographyService.decrypt(item)
}
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}.eraseToAnyPublisher()
}
}

extension DefaultAuthenticatorItemRepository: AuthenticatorItemRepository {
Expand Down Expand Up @@ -147,6 +189,8 @@ extension DefaultAuthenticatorItemRepository: AuthenticatorItemRepository {
try await authenticatorItemService.updateAuthenticatorItem(item)
}

// MARK: Publishers

func authenticatorItemDetailsPublisher(
id: String
) async throws -> AsyncThrowingPublisher<AnyPublisher<AuthenticatorItemView?, Error>> {
Expand All @@ -167,4 +211,16 @@ extension DefaultAuthenticatorItemRepository: AuthenticatorItemRepository {
.eraseToAnyPublisher()
.values
}

func searchItemListPublisher(
searchText: String
) async throws -> AsyncThrowingPublisher<AnyPublisher<[ItemListItem], Error>> {
try await searchPublisher(
searchText: searchText
).asyncTryMap { items in
items.compactMap(ItemListItem.init)
}
.eraseToAnyPublisher()
.values
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class MockAuthenticatorItemRepository: AuthenticatorItemRepository {
var authenticatorItemDetailsSubject = CurrentValueSubject<AuthenticatorItemView?, Error>(nil)
var itemListSubject = CurrentValueSubject<[ItemListSection], Error>([])

var searchItemListSubject = CurrentValueSubject<[ItemListItem], Error>([])

var updateAuthenticatorItemItems = [AuthenticatorItemView]()
var updateAuthenticatorItemResult: Result<Void, Error> = .success(())

Expand Down Expand Up @@ -68,4 +70,10 @@ class MockAuthenticatorItemRepository: AuthenticatorItemRepository {
updateAuthenticatorItemItems.append(authenticatorItem)
try updateAuthenticatorItemResult.get()
}

func searchItemListPublisher(
searchText: String
) async throws -> AsyncThrowingPublisher<AnyPublisher<[AuthenticatorShared.ItemListItem], Error>> {
searchItemListSubject.eraseToAnyPublisher().values
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import SwiftUI

// MARK: - SearchNoResultsView

/// A view that displays the no search results image and text.
///
struct SearchNoResultsView<Content: View>: View {
// MARK: Properties

/// An optional view to display at the top of the scroll view above the no results image and text.
var headerView: Content?

// MARK: View

var body: some View {
GeometryReader { reader in
ScrollView {
VStack(spacing: 0) {
if let headerView {
headerView
}

VStack(spacing: 35) {
Image(decorative: Asset.Images.magnifyingGlass)
.resizable()
.frame(width: 74, height: 74)
.foregroundColor(Asset.Colors.textSecondary.swiftUIColor)

Text(Localizations.thereAreNoItemsThatMatchTheSearch)
.multilineTextAlignment(.center)
.styleGuide(.callout)
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
}
.accessibilityIdentifier("NoSearchResultsLabel")
.frame(maxWidth: .infinity, minHeight: reader.size.height, maxHeight: .infinity)
}
}
}
.background(Color(asset: Asset.Colors.backgroundSecondary))
}

// MARK: Initialization

/// Initialize a `SearchNoResultsView`.
///
init() where Content == EmptyView {
headerView = nil
}

/// Initialize a `SearchNoResultsView` with a header view.
///
/// - Parameter headerView: An optional view to display at the top of the scroll view above the
/// no results image and text.
///
init(headerView: () -> Content) {
self.headerView = headerView()
}
}

// MARK: - Previews

#Preview("No Results") {
SearchNoResultsView()
}

#Preview("No Results With Header") {
SearchNoResultsView {
Text("Optional header text!")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ enum ItemListAction: Equatable {
///
case itemPressed(_ item: ItemListItem)

/// The user has started or stopped searching.
case searchStateChanged(isSearching: Bool)

/// The text in the search bar was changed.
case searchTextChanged(String)

/// The toast was shown or hidden.
case toastShown(Toast?)
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ enum ItemListEffect: Equatable {
/// The refresh control was triggered.
case refresh

/// Searches based on the keyword.
case search(String)

/// Stream the vault list for the user.
case streamItemList
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
await showMoreOptionsAlert(for: item)
case .refresh:
await streamItemList()
case let .search(text):
state.searchResults = await searchItems(for: text)
case .streamItemList:
await streamItemList()
}
Expand All @@ -83,6 +85,14 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
services.pasteboardService.copy(model.totpCode.code)
state.toast = Toast(text: Localizations.valueHasBeenCopied(Localizations.verificationCode))
}
case let .searchStateChanged(isSearching: isSearching):
guard isSearching else {
state.searchText = ""
state.searchResults = []
return
}
case let .searchTextChanged(newValue):
state.searchText = newValue
case let .toastShown(newValue):
state.toast = newValue
}
Expand Down Expand Up @@ -196,6 +206,29 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
}
}

/// Searches items using the provided string, and returns any matching results.
///
/// - Parameters:
/// - searchText: The string to use when searching items.
/// - Returns: An array of `ItemListItem` objects. If no results can be found, an empty array will be returned.
///
private func searchItems(for searchText: String) async -> [ItemListItem] {
guard !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return []
}
do {
let result = try await services.authenticatorItemRepository.searchItemListPublisher(
searchText: searchText
)
for try await items in result {
return items
}
} catch {
services.errorReporter.log(error: error)
}
return []
}

/// Stream the items list.
private func streamItemList() async {
do {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ struct ItemListState: Equatable {
/// The current loading state.
var loadingState: LoadingState<[ItemListItem]> = .loading(nil)

/// An array of results matching the `searchText`.
var searchResults = [ItemListItem]()

/// The text that the user is currently searching for.
var searchText = ""

/// Whether to show the add item button in the view.
var showAddItemButton: Bool {
// Don't show if there is data.
Expand Down
Loading

0 comments on commit a70ce1d

Please sign in to comment.