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 all 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 @@
//
// DynamicFacetListTableViewController.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 DynamicFacetListTableViewController: UITableViewController, DynamicFacetListController {

/// 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: - DynamicFacetListController

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 attribute.
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 @@
//
// DynamicFacetListConnector+Controller.swift
//
//
// Created by Vladislav Fitc on 17/06/2021.
//

import Foundation

public extension DynamicFacetListConnector {

/**
- parameters:
- searcher: Searcher that handles your searches
- filterState: FilterState that holds your filters
- interactor: External dynamic facet list 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: Controller presenting the ordered list of facets and handling the user interaction

If no filter group descriptor provided, the filters for attribute will be automatically stored in the conjunctive (`and`) group with the facet attribute name.
*/
convenience init<Controller: DynamicFacetListController>(searcher: Searcher,
filterState: FilterState = .init(),
interactor: DynamicFacetListInteractor = .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: Controller presenting the ordered list of facets and handling the user interaction

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

/**
Establishes a connection with a DynamicFacetListController implementation
- parameter controller: Controller presenting the ordered list of facets and handling the user interaction
*/
@discardableResult func connectController<Controller: DynamicFacetListController>(_ controller: Controller) -> DynamicFacetListInteractor.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 @@
//
// DynamicFacetListConnector.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 DynamicFacetListConnector<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: DynamicFacetListInteractor

/// 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 facet list 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 stored in the conjunctive (`and`) group with the facet attribute name.
*/
public init(searcher: Searcher,
filterState: FilterState = .init(),
interactor: DynamicFacetListInteractor,
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 stored in the conjunctive (`and`) group with the facet attribute name.
*/
public convenience init(searcher: Searcher,
filterState: FilterState = .init(),
orderedFacets: [AttributedFacets] = [],
selections: [Attribute: Set<String>] = [:],
selectionModeForAttribute: [Attribute: SelectionMode] = [:],
filterGroupForAttribute: [Attribute: FilterGroupDescriptor] = [:]) {
let interactor = DynamicFacetListInteractor(orderedFacets: orderedFacets,
selections: selections,
selectionModeForAttribute: selectionModeForAttribute)
self.init(searcher: searcher,
filterState: filterState,
interactor: interactor,
filterGroupForAttribute: filterGroupForAttribute)
}

}

extension DynamicFacetListConnector: 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 @@
//
// DynamicFacetListController.swift
//
//
// Created by Vladislav Fitc on 04/06/2021.
//

import Foundation

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

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

/// Update the facets 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 @@
//
// DynamicFacetListInteractor+Controller.swift
//
//
// Created by Vladislav Fitc on 16/03/2021.
//

import Foundation

public extension DynamicFacetListInteractor {

/// Connection between a dynamic facet list business logic and a controller
struct ControllerConnection<Controller: DynamicFacetListController>: Connection {

/// Dynamic facet list business logic
public let interactor: DynamicFacetListInteractor

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

/**
- parameters:
- interactor: Dynamic facets business logic
- controller: Controller presenting the ordered list of facets and handling the user interaction
*/
public init(interactor: DynamicFacetListInteractor,
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 DynamicFacetListController implementation
- parameter controller: Controller presenting the ordered list of facets and handling the user interaction
*/
@discardableResult func connectController<Controller: DynamicFacetListController>(_ controller: Controller) -> ControllerConnection<Controller> {
let connection = ControllerConnection(interactor: self, controller: controller)
connection.connect()
return connection
}

}
Loading