Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Dynamic Faceting widget #168

Merged
merged 24 commits into from
Jul 13, 2021
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ let package = Package(
targets: ["InstantSearchInsights"])
],
dependencies: [
.package(name: "AlgoliaSearchClient", url: "https://github.com/algolia/algoliasearch-client-swift", from: "8.8.0")
.package(name: "AlgoliaSearchClient", url: "https://github.com/algolia/algoliasearch-client-swift", .branch("feat/dynamic-faceting"))
],
targets: [
.target(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//
// DynamicFacetsTableViewController.swift
//
//
// Created by Vladislav Fitc on 16/03/2021.
//

#if !InstantSearchCocoaPods
import InstantSearchCore
#endif
#if canImport(UIKit) && (os(iOS) || os(macOS))
import UIKit

/// Table view controller presenting ordered facets and ordered facet values
/// Each facet and corresponding values are represented as a table view section
public class DynamicFacetsTableViewController: UITableViewController, DynamicFacetsController {

/// List of ordered facets with their attributes
public var orderedFacets: [AttributedFacets]

/// Set of selected facet values per attribute
public var selections: [Attribute: Set<String>]

// MARK: - DynamicFacetsController

public var didSelect: ((Attribute, Facet) -> Void)?

public func setSelections(_ selections: [Attribute: Set<String>]) {
self.selections = selections
tableView.reloadData()
}

public func setOrderedFacets(_ orderedFacets: [AttributedFacets]) {
self.orderedFacets = orderedFacets
tableView.reloadData()
}

/**
- parameters:
- orderedFacets: List of ordered facets with their attributes
- selections: Set of selected facet values per attribute
*/
public init(orderedFacets: [AttributedFacets] = [],
selections: [Attribute: Set<String>] = [:]) {
self.orderedFacets = orderedFacets
self.selections = selections
super.init(style: .plain)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

public override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
}

// MARK: - UITableViewDataSource

public override func numberOfSections(in tableView: UITableView) -> Int {
return orderedFacets.count
}

public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return orderedFacets[section].facets.count
}

public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
return tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
}

// MARK: - UITableViewDelegate

public override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return orderedFacets[section].attribute.rawValue
}

public override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
let attribute = orderedFacets[indexPath.section].attribute
let facet = orderedFacets[indexPath.section].facets[indexPath.row]
cell.textLabel?.text = facet.description
cell.accessoryType = (selections[attribute]?.contains(facet.value) ?? false) ? .checkmark : .none
}

public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let unit = orderedFacets[indexPath.section]
let facet = unit.facets[indexPath.row]
didSelect?(unit.attribute, facet)
}

}
#endif
35 changes: 35 additions & 0 deletions Sources/InstantSearchCore/DynamicFacets/AttributedFacets.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// AttributedFacets.swift
//
//
// Created by Vladislav Fitc on 17/03/2021.
//

import Foundation

/// List of ordered facets with their attributes
public struct AttributedFacets: Codable {

/// Facet attribute
public let attribute: Attribute

/// List of ordered facet values
public let facets: [Facet]

enum CodingKeys: String, CodingKey {
case attribute
case facets = "values"
}

/**
- parameters:
- attribute: Facet attribute
- facets: List of ordered facet values
*/
public init(attribute: Attribute,
facets: [Facet] = []) {
self.attribute = attribute
self.facets = facets
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//
// DynamicFacetsConnector+Controller.swift
//
//
// Created by Vladislav Fitc on 17/06/2021.
//

import Foundation

public extension DynamicFacetsConnector {

/**
- parameters:
- searcher: Searcher that handles your searches
- filterState: FilterState that holds your filters
- interactor: External dynamic facets interactor
- filterGroupForAttribute: Mapping between a facet attribute and a descriptor of a filter group where the corresponding facet filters stored in the filter state.
- controller: DynamicFacetsController implementation to connect

If no filter group descriptor provided, the filters for attribute will be automatically stroed in the conjunctive (`and`) group with the facet attribute name.
VladislavFitz marked this conversation as resolved.
Show resolved Hide resolved
*/
convenience init<Controller: DynamicFacetsController>(searcher: Searcher,
filterState: FilterState = .init(),
interactor: DynamicFacetsInteractor = .init(),
filterGroupForAttribute: [Attribute: FilterGroupDescriptor] = [:],
controller: Controller) {
self.init(searcher: searcher,
filterState: filterState,
interactor: interactor,
filterGroupForAttribute: filterGroupForAttribute)
connectController(controller)
}

/**
- parameters:
- searcher: Searcher that handles your searches
- filterState: FilterState that holds your filters
- orderedFacets: Ordered list of attributed facets
- selections: Mapping between a facet attribute and a set of selected facet values
- selectionModeForAttribute: Mapping between a facet attribute and a facet values selection mode. If not provided, the default selection mode is .single.
- filterGroupForAttribute: Mapping between a facet attribute and a descriptor of a filter group where the corresponding facet filters stored in the filter state.
- controller: DynamicFacetsController implementation to connect

If no filter group descriptor provided, the filters for attribute will be automatically stroed in the conjunctive (`and`) group with the facet attribute name.
VladislavFitz marked this conversation as resolved.
Show resolved Hide resolved
*/
convenience init<Controller: DynamicFacetsController>(searcher: Searcher,
filterState: FilterState = .init(),
orderedFacets: [AttributedFacets] = [],
selections: [Attribute: Set<String>] = [:],
selectionModeForAttribute: [Attribute: SelectionMode] = [:],
filterGroupForAttribute: [Attribute: FilterGroupDescriptor] = [:],
controller: Controller) {
let interactor = DynamicFacetsInteractor(orderedFacets: orderedFacets,
selections: selections,
selectionModeForAttribute: selectionModeForAttribute)
self.init(searcher: searcher,
filterState: filterState,
interactor: interactor,
filterGroupForAttribute: filterGroupForAttribute)
connectController(controller)
}

/**
Establishes a connection with a DynamicFacetsController implementation
- parameter controller: DynamicFacetsController implementation to connect
*/
@discardableResult func connectController<Controller: DynamicFacetsController>(_ controller: Controller) -> DynamicFacetsInteractor.ControllerConnection<Controller> {
let connection = interactor.connectController(controller)
controllerConnections.append(connection)
return connection
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
//
// DynamicFacetsConnector.swift
//
//
// Created by Vladislav Fitc on 17/06/2021.
//

import Foundation

/// Component that displays automatically ordered facets, their ordered values, and lets the user refine the search results by filtering on specific values.

public class DynamicFacetsConnector<Searcher: SearchResultObservable> where Searcher.SearchResult == SearchResponse {

/// Searcher that handles your searches.
public let searcher: Searcher

/// FilterState that holds your filters
public let filterState: FilterState

/// Logic applied to the facets
public let interactor: DynamicFacetsInteractor

/// Connection between interactor and filter state
public let filterStateConnection: Connection

/// Connection between interactor and searcher
public let searcherConnection: Connection

/// Connections between interactor and controllers
public var controllerConnections: [Connection]

/**
- parameters:
- searcher: Searcher that handles your searches
- filterState: FilterState that holds your filters
- interactor: External dynamic facets interactor
- filterGroupForAttribute: Mapping between a facet attribute and a descriptor of a filter group where the corresponding facet filters stored in the filter state.

If no filter group descriptor provided, the filters for attribute will be automatically stroed in the conjunctive (`and`) group with the facet attribute name.
VladislavFitz marked this conversation as resolved.
Show resolved Hide resolved
*/
public init(searcher: Searcher,
filterState: FilterState = .init(),
interactor: DynamicFacetsInteractor,
filterGroupForAttribute: [Attribute: FilterGroupDescriptor] = [:]) {
self.searcher = searcher
self.filterState = filterState
self.interactor = interactor
self.controllerConnections = []
searcherConnection = interactor.connectSearcher(searcher)
filterStateConnection = interactor.connectFilterState(filterState,
filterGroupForAttribute: filterGroupForAttribute)
}

/**
- parameters:
- searcher: Searcher that handles your searches
- filterState: FilterState that holds your filters
- orderedFacets: Ordered list of attributed facets
- selections: Mapping between a facet attribute and a set of selected facet values
- selectionModeForAttribute: Mapping between a facet attribute and a facet values selection mode. If not provided, the default selection mode is .single.
- filterGroupForAttribute: Mapping between a facet attribute and a descriptor of a filter group where the corresponding facet filters stored in the filter state.

If no filter group descriptor provided, the filters for attribute will be automatically stroed in the conjunctive (`and`) group with the facet attribute name.
VladislavFitz marked this conversation as resolved.
Show resolved Hide resolved
*/
public convenience init(searcher: Searcher,
filterState: FilterState = .init(),
orderedFacets: [AttributedFacets] = [],
selections: [Attribute: Set<String>] = [:],
selectionModeForAttribute: [Attribute: SelectionMode] = [:],
filterGroupForAttribute: [Attribute: FilterGroupDescriptor] = [:]) {
let interactor = DynamicFacetsInteractor(orderedFacets: orderedFacets,
selections: selections,
selectionModeForAttribute: selectionModeForAttribute)
self.init(searcher: searcher,
filterState: filterState,
interactor: interactor,
filterGroupForAttribute: filterGroupForAttribute)
}

}

extension DynamicFacetsConnector: Connection {

public func connect() {
filterStateConnection.connect()
searcherConnection.connect()
controllerConnections.forEach { $0.connect() }
}

public func disconnect() {
filterStateConnection.disconnect()
searcherConnection.disconnect()
controllerConnections.forEach { $0.disconnect() }
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// DynamicFacetsController.swift
//
//
// Created by Vladislav Fitc on 04/06/2021.
//

import Foundation

/**
Controller presenting the ordered list of facets and handling user interaction
*/
public protocol DynamicFacetsController: AnyObject {

/// Update the list of the ordered attributed facets
func setOrderedFacets(_ orderedFacets: [AttributedFacets])

/// Update the facet selections
func setSelections(_ selections: [Attribute: Set<String>])

/// A closure to trigger when user selects a facet
var didSelect: ((Attribute, Facet) -> Void)? { get set }

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//
// DynamicFacetsInteractor+Controller.swift
//
//
// Created by Vladislav Fitc on 16/03/2021.
//

import Foundation

public extension DynamicFacetsInteractor {

/// Connection between a dynamic facets business logic and a controller
struct ControllerConnection<Controller: DynamicFacetsController>: Connection {

/// Dynamic facets business logic
public let interactor: DynamicFacetsInteractor

/// Controller presenting the ordered list of facets and handling user interaction
public let controller: Controller

/**
- parameters:
- interactor: Dynamic facets business logic
- controller: DynamicFacetsController implementation to connect
*/
public init(interactor: DynamicFacetsInteractor,
controller: Controller) {
self.interactor = interactor
self.controller = controller
}

public func connect() {
controller.didSelect = { [weak interactor] attribute, facet in
guard let interactor = interactor else { return }
interactor.toggleSelection(ofFacetValue: facet.value, for: attribute)
}
interactor.onSelectionsChanged.subscribePast(with: controller) { (controller, selections) in
controller.setSelections(selections)
}.onQueue(.main)

interactor.onFacetOrderChanged.subscribePast(with: controller) { controller, orderedFacets in
controller.setOrderedFacets(orderedFacets)
}.onQueue(.main)
}

public func disconnect() {
controller.didSelect = nil
interactor.onSelectionsChanged.cancelSubscription(for: controller)
interactor.onFacetOrderChanged.cancelSubscription(for: controller)
}

}

/**
Establishes a connection with a DynamicFacetsController implementation
- parameter controller: DynamicFacetsController implementation to connect
*/
@discardableResult func connectController<Controller: DynamicFacetsController>(_ controller: Controller) -> ControllerConnection<Controller> {
let connection = ControllerConnection(interactor: self, controller: controller)
connection.connect()
return connection
}

}
Loading