Merge branch 'search' into requestBuilder

# Conflicts:
#	EmployeeDirectory/ViewControllers/EmployeesViewController.swift
#	EmployeeDirectory/ViewModels/EmployeesViewModel.swift

Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
Matt Bruce 2025-01-31 12:53:45 -06:00
commit 9c8782dea8
2 changed files with 87 additions and 12 deletions

View File

@ -16,6 +16,9 @@ class EmployeesViewController: UIViewController {
/// List for the employees
private let tableView = UITableView()
/// Filtering the employees
private let searchBar = UISearchBar()
/// Will only show when fetching data occurs
private let activityIndicator = UIActivityIndicatorView(style: .large)
@ -73,11 +76,40 @@ class EmployeesViewController: UIViewController {
view.backgroundColor = .white
// Configure TableView
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.register(EmployeeTableViewCell.self, forCellReuseIdentifier: EmployeeTableViewCell.identifier)
tableView.delegate = self
view.addSubview(tableView)
tableView.frame = view.bounds
tableView.estimatedRowHeight = 80 // Provide a reasonable default height
tableView.rowHeight = UITableView.automaticDimension // Enable dynamic row height
// Configure Segmented Control
let segmentedContainer = UIView()
segmentedContainer.translatesAutoresizingMaskIntoConstraints = false
segmentedContainer.addSubview(modeSegmentedControl)
modeSegmentedControl.selectedSegmentIndex = 0
modeSegmentedControl.addTarget(self, action: #selector(onServiceModeChange), for: .valueChanged)
modeSegmentedControl.translatesAutoresizingMaskIntoConstraints = false
let stackView = UIStackView(arrangedSubviews: [segmentedContainer, tableView])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
stackView.distribution = .fill
stackView.spacing = 5
view.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
stackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
modeSegmentedControl.topAnchor.constraint(equalTo: segmentedContainer.topAnchor),
modeSegmentedControl.bottomAnchor.constraint(equalTo: segmentedContainer.bottomAnchor, constant: -5),
modeSegmentedControl.leadingAnchor.constraint(equalTo: segmentedContainer.leadingAnchor, constant: 16),
modeSegmentedControl.trailingAnchor.constraint(equalTo: segmentedContainer.trailingAnchor, constant: -16)
])
//add pull to refresh
tableView.refreshControl = UIRefreshControl()
tableView.refreshControl?.addTarget(self, action: #selector(didPullToRefresh), for: .valueChanged)
@ -85,14 +117,14 @@ class EmployeesViewController: UIViewController {
// Configure Activity Indicator
activityIndicator.center = view.center
view.addSubview(activityIndicator)
// Configure Mode Selector
modeSegmentedControl.selectedSegmentIndex = 0
modeSegmentedControl.addTarget(self, action: #selector(onServiceModeChange), for: .valueChanged)
navigationItem.titleView = modeSegmentedControl
let sortButton = UIBarButtonItem(title: "Sort", style: .plain, target: self, action: #selector(showSortOptions))
navigationItem.rightBarButtonItem = sortButton
/// setup Search
searchBar.placeholder = "Search employees"
searchBar.delegate = self
navigationItem.titleView = searchBar
}
private func presentSortingActionSheet() {
@ -124,7 +156,6 @@ class EmployeesViewController: UIViewController {
})
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
present(alert, animated: true, completion: nil)
}
/// Using the ViewModel setup combine handlers
@ -132,6 +163,7 @@ class EmployeesViewController: UIViewController {
viewModel.$employees
.receive(on: RunLoop.main)
.sink { [weak self] employees in
self?.updateFooter()
self?.applySnapshot(employees: employees)
}
.store(in: &cancellables)
@ -183,7 +215,7 @@ class EmployeesViewController: UIViewController {
var footerMessage: String?
// Check for error messages or empty state first
if let message = viewModel.errorMessage ?? (viewModel.employees.isEmpty && viewModel.isLoading == false ? "No employees found, please try to refresh." : nil) {
if let message = viewModel.errorMessage ?? (viewModel.employees.isEmpty && viewModel.isLoading == false ? "No employees found." : nil) {
footerMessage = message
}
@ -255,3 +287,15 @@ extension EmployeesViewController {
viewModel.changeService(to: selectedMode.service)
}
}
extension EmployeesViewController: UISearchBarDelegate {
public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
viewModel.searchText = searchText // Updates search text in ViewModel
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
searchBar.text = ""
searchBar.resignFirstResponder()
viewModel.searchText = "" // Clear search and reset list
}
}

View File

@ -16,11 +16,12 @@ public class EmployeesViewModel: ObservableObject {
private var serviceMode: EmployeeServiceMode = .production
private var employeeService: EmployeeServiceProtocol = MockEmployeeService.shared
private var allEmployees: [Employee] = []
@Published public private(set) var employees: [Employee] = []
@Published public private(set) var errorMessage: String? = nil
@Published public private(set) var isLoading: Bool = false
@Published public private(set) var hasMorePages: Bool = true
@Published public var searchText: String = ""
@Published public var sortField: EmployeeSortField = .fullName
@Published public var sortOrder: EmployeeSortOrder = .ascending
private var cancellables = Set<AnyCancellable>()
@ -30,9 +31,21 @@ public class EmployeesViewModel: ObservableObject {
private var totalEmployees = 0
public init() {
setupSearch()
observeSortingChanges()
}
/// Sets up Combine to filter employees as the search text changes
private func setupSearch() {
$searchText
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) // Add delay to avoid frequent filtering
.removeDuplicates()
.sink { [weak self] query in
self?.filterEmployees(by: query)
}
.store(in: &cancellables)
}
/// Observe changes to sortField and sortOrder and debounce fetch calls
private func observeSortingChanges() {
Publishers.CombineLatest($sortField, $sortOrder)
@ -49,6 +62,8 @@ public class EmployeesViewModel: ObservableObject {
// Prevent duplicate calls
guard !isLoading else { return }
// resetting values out the values before fetching new data
errorMessage = nil
isLoading = true
Task {
@ -65,10 +80,11 @@ public class EmployeesViewModel: ObservableObject {
// Update published properties
if page == 1 {
employees = wrapper.employees // Replace list for the first page
allEmployees = wrapper.employees // Replace list for the first page
} else {
employees.append(contentsOf: wrapper.employees) // Append for subsequent pages
allEmployees.append(contentsOf: wrapper.employees) // Append for subsequent pages
}
filterEmployees(by: searchText)
} catch {
// Handle errors
@ -89,6 +105,7 @@ public class EmployeesViewModel: ObservableObject {
private func resetAndFetchEmployees() {
currentPage = 1
employees = []
allEmployees = []
hasMorePages = true
fetchEmployees(page: 1)
}
@ -97,4 +114,18 @@ public class EmployeesViewModel: ObservableObject {
self.employeeService = employeeService
resetAndFetchEmployees()
}
/// Filters employees based on search query
private func filterEmployees(by query: String) {
if query.isEmpty {
employees = allEmployees // Reset to all employees if no search text
} else {
employees = allEmployees.filter { employee in
employee.fullName.lowercased().contains(query.lowercased()) ||
employee.team.lowercased().contains(query.lowercased()) ||
employee.emailAddress.lowercased().contains(query.lowercased())
}
}
}
}