diff --git a/Modules/Sources/Networking/Remote/BookingsRemote.swift b/Modules/Sources/Networking/Remote/BookingsRemote.swift index 740e17abe86..b33aa2b5b71 100644 --- a/Modules/Sources/Networking/Remote/BookingsRemote.swift +++ b/Modules/Sources/Networking/Remote/BookingsRemote.swift @@ -25,6 +25,10 @@ public protocol BookingsRemoteProtocol { func fetchResource(resourceID: Int64, siteID: Int64) async throws -> BookingResource? + + func fetchResources(for siteID: Int64, + pageNumber: Int, + pageSize: Int) async throws -> [BookingResource] } /// Booking: Remote Endpoints @@ -133,6 +137,37 @@ public final class BookingsRemote: Remote, BookingsRemoteProtocol { return try await enqueue(request, mapper: mapper) } + + /// Retrieves all of the `BookingResources` available. + /// + /// - Parameters: + /// - siteID: Site for which we'll fetch remote booking resources. + /// - pageNumber: Number of page that should be retrieved. + /// - pageSize: Number of resources to be retrieved per page. + /// + public func fetchResources( + for siteID: Int64, + pageNumber: Int = Default.pageNumber, + pageSize: Int = Default.pageSize + ) async throws -> [BookingResource] { + let parameters = [ + ParameterKey.page: String(pageNumber), + ParameterKey.perPage: String(pageSize) + ] + + let path = Path.resources + let request = JetpackRequest( + wooApiVersion: .wcBookings, + method: .get, + siteID: siteID, + path: path, + parameters: parameters, + availableAsRESTRequest: true + ) + let mapper = ListMapper(siteID: siteID) + + return try await enqueue(request, mapper: mapper) + } } // MARK: - Constants diff --git a/Modules/Sources/Storage/Tools/StorageType+Extensions.swift b/Modules/Sources/Storage/Tools/StorageType+Extensions.swift index 568ecdef1d0..a0849f1edc8 100644 --- a/Modules/Sources/Storage/Tools/StorageType+Extensions.swift +++ b/Modules/Sources/Storage/Tools/StorageType+Extensions.swift @@ -967,6 +967,13 @@ public extension StorageType { return objects.isEmpty ? nil : objects } + /// Retrieves the store booking resources + func loadBookingResources(siteID: Int64, resourceIDs: [Int64]) -> [BookingResource] { + let predicate = NSPredicate(format: "siteID == %lld && resourceID in %@", siteID, resourceIDs) + let descriptor = NSSortDescriptor(keyPath: \BookingResource.resourceID, ascending: false) + return allObjects(ofType: BookingResource.self, matching: predicate, sortedBy: [descriptor]) + } + /// Retrieves the store booking resource func loadBookingResource(siteID: Int64, resourceID: Int64) -> BookingResource? { let predicate = \BookingResource.resourceID == resourceID && \BookingResource.siteID == siteID diff --git a/Modules/Sources/Yosemite/Actions/BookingAction.swift b/Modules/Sources/Yosemite/Actions/BookingAction.swift index 4e0d3a4967b..095089d2ef4 100644 --- a/Modules/Sources/Yosemite/Actions/BookingAction.swift +++ b/Modules/Sources/Yosemite/Actions/BookingAction.swift @@ -53,6 +53,15 @@ public enum BookingAction: Action { resourceID: Int64, onCompletion: (Result) -> Void) + /// Synchronizes booking resources matching the specified criteria. + /// + /// - Parameter onCompletion: called when sync completes, returns an error or a boolean that indicates whether there might be more resources to sync. + /// + case synchronizeResources(siteID: Int64, + pageNumber: Int, + pageSize: Int = BookingsRemote.Default.pageSize, + onCompletion: (Result) -> Void) + /// Updates a booking attendance status. /// /// - Parameter siteID: The site ID of the booking. diff --git a/Modules/Sources/Yosemite/Model/Model.swift b/Modules/Sources/Yosemite/Model/Model.swift index 2492b5ffb42..5b683a0ad65 100644 --- a/Modules/Sources/Yosemite/Model/Model.swift +++ b/Modules/Sources/Yosemite/Model/Model.swift @@ -277,6 +277,7 @@ public typealias StorageBlazeTargetLanguage = Storage.BlazeTargetLanguage public typealias StorageBlazeTargetTopic = Storage.BlazeTargetTopic // periphery: ignore public typealias StorageBooking = Storage.Booking +public typealias StorageBookingResource = Storage.BookingResource public typealias StorageCardReaderType = Storage.CardReaderType public typealias StorageCoupon = Storage.Coupon public typealias StorageCustomer = Storage.Customer diff --git a/Modules/Sources/Yosemite/Stores/BookingStore.swift b/Modules/Sources/Yosemite/Stores/BookingStore.swift index 5f2927f6643..916087b0a96 100644 --- a/Modules/Sources/Yosemite/Stores/BookingStore.swift +++ b/Modules/Sources/Yosemite/Stores/BookingStore.swift @@ -63,6 +63,8 @@ public class BookingStore: Store { onCompletion: onCompletion) case let .fetchResource(siteID, resourceID, onCompletion): fetchResource(siteID: siteID, resourceID: resourceID, onCompletion: onCompletion) + case let .synchronizeResources(siteID, pageNumber, pageSize, onCompletion): + synchronizeResources(siteID: siteID, pageNumber: pageNumber, pageSize: pageSize, onCompletion: onCompletion) case .updateBookingAttendanceStatus(let siteID, let bookingID, let status, let onCompletion): performUpdateBookingAttendanceStatus( siteID: siteID, @@ -244,7 +246,8 @@ private extension BookingStore { return } - await upsertBookingResourceInBackground(readOnlyBookingResource: resource) + await upsertBookingResourcesInBackground(siteID: resource.siteID, + readOnlyBookingResources: [resource]) onCompletion(.success(resource)) } catch { @@ -253,6 +256,31 @@ private extension BookingStore { } } + /// Synchronizes booking resources for the specified site. + /// + func synchronizeResources(siteID: Int64, + pageNumber: Int, + pageSize: Int, + onCompletion: @escaping (Result) -> Void) { + Task { @MainActor in + do { + let resources = try await remote.fetchResources( + for: siteID, + pageNumber: pageNumber, + pageSize: pageSize + ) + + await upsertBookingResourcesInBackground(siteID: siteID, + readOnlyBookingResources: resources) + + let hasNextPage = resources.count == pageSize + onCompletion(.success(hasNextPage)) + } catch { + onCompletion(.failure(error)) + } + } + } + func performUpdateBookingAttendanceStatus( siteID: Int64, bookingID: Int64, @@ -393,16 +421,22 @@ private extension BookingStore { } /// Updates (OR Inserts) the specified ReadOnly BookingResource Entities *in a background thread* async. - func upsertBookingResourceInBackground(readOnlyBookingResource: BookingResource) async { + func upsertBookingResourcesInBackground(siteID: Int64, readOnlyBookingResources: [BookingResource]) async { await withCheckedContinuation { [weak self] continuation in guard let self else { return continuation.resume() } storageManager.performAndSave({ storage in - let storedItem = storage.loadBookingResource(siteID: readOnlyBookingResource.siteID, resourceID: readOnlyBookingResource.resourceID) - let storageResource = storedItem ?? storage.insertNewObject(ofType: Storage.BookingResource.self) - storageResource.update(with: readOnlyBookingResource) + let storedItems = storage.loadBookingResources( + siteID: siteID, + resourceIDs: readOnlyBookingResources.map { $0.resourceID } + ) + for item in readOnlyBookingResources { + let storageResource = storedItems.first(where: { $0.resourceID == item.resourceID }) ?? + storage.insertNewObject(ofType: Storage.BookingResource.self) + storageResource.update(with: item) + } }, completion: { continuation.resume() }, on: .main) diff --git a/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift index 78eca50eaef..ca2e7946ab2 100644 --- a/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift @@ -123,4 +123,47 @@ struct BookingsRemoteTests { _ = try await remote.fetchResource(resourceID: 22, siteID: sampleSiteID) } } + + @Test func test_fetchResources_properly_returns_parsed_resources() async throws { + // Given + let remote = BookingsRemote(network: network) + network.simulateResponse(requestUrlSuffix: "resources/team-members", filename: "booking-resource-list") + + // When + let resources = try await remote.fetchResources(for: sampleSiteID) + + // Then + #expect(resources.count == 2) + let firstResource = try #require(resources.first) + #expect(firstResource.resourceID == 22) + #expect(firstResource.name == "Joel (Sample resource)") + #expect(firstResource.quantity == 1) + #expect(firstResource.siteID == sampleSiteID) + } + + @Test func test_fetchResources_properly_relays_networking_errors() async { + // Given + let remote = BookingsRemote(network: network) + + // Then + await #expect(throws: NetworkError.notFound()) { + _ = try await remote.fetchResources(for: sampleSiteID) + } + } + + @Test func test_fetchResources_sends_correct_parameters() async throws { + // Given + let remote = BookingsRemote(network: network) + network.simulateResponse(requestUrlSuffix: "resources/team-members", filename: "booking-resource-list") + + // When + _ = try await remote.fetchResources(for: sampleSiteID, pageNumber: 3, pageSize: 100) + + // Then + let request = try #require(network.requestsForResponseData.first as? JetpackRequest) + let parameters = request.parameters + + #expect((parameters["page"] as? String) == "3") + #expect((parameters["per_page"] as? String) == "100") + } } diff --git a/Modules/Tests/NetworkingTests/Responses/booking-resource-list.json b/Modules/Tests/NetworkingTests/Responses/booking-resource-list.json new file mode 100644 index 00000000000..58fc4c097bf --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/booking-resource-list.json @@ -0,0 +1,26 @@ +[ + { + "id": 22, + "name": "Joel (Sample resource)", + "qty": 1, + "role": "", + "email": "", + "phone_number": "", + "image_id": 0, + "image_url": "", + "description": "", + "note": "" + }, + { + "id": 23, + "name": "Sarah (Sample resource)", + "qty": 2, + "role": "Manager", + "email": "sarah@example.com", + "phone_number": "+1234567890", + "image_id": 45, + "image_url": "https://example.com/image.jpg", + "description": "Sample resource description", + "note": "Sample note" + } +] diff --git a/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift index c9d0200cc4e..76ee035d4e7 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift @@ -7,6 +7,7 @@ final class MockBookingsRemote: BookingsRemoteProtocol { private var loadAllBookingsResult: Result<[Booking], Error>? private var loadBookingResult: Result? private var fetchResourceResult: Result? + private var fetchResourcesResult: Result<[BookingResource], Error>? func whenLoadingAllBookings(thenReturn result: Result<[Booking], Error>) { loadAllBookingsResult = result @@ -20,6 +21,10 @@ final class MockBookingsRemote: BookingsRemoteProtocol { fetchResourceResult = result } + func whenFetchingResources(thenReturn result: Result<[BookingResource], Error>) { + fetchResourcesResult = result + } + func loadAllBookings(for siteID: Int64, pageNumber: Int, pageSize: Int, @@ -50,4 +55,11 @@ final class MockBookingsRemote: BookingsRemoteProtocol { func updateBooking(from siteID: Int64, bookingID: Int64, attendanceStatus: Networking.BookingAttendanceStatus) async throws -> Networking.Booking? { return nil } + + func fetchResources(for siteID: Int64, pageNumber: Int, pageSize: Int) async throws -> [Networking.BookingResource] { + guard let result = fetchResourcesResult else { + throw NetworkError.timeout() + } + return try result.get() + } } diff --git a/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift b/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift index fb01fa18c5c..153b911a3e1 100644 --- a/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift +++ b/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift @@ -36,6 +36,12 @@ struct BookingStoreTests { return viewStorage.countObjects(ofType: StorageBooking.self) } + /// Convenience: returns the number of stored booking resources + /// + private var storedBookingResourceCount: Int { + return viewStorage.countObjects(ofType: Storage.BookingResource.self) + } + /// SiteID /// private let sampleSiteID: Int64 = 120934 @@ -618,6 +624,202 @@ struct BookingStoreTests { #expect(orderInfo.statusKey == "processing") } + // MARK: - synchronizeResources + + @Test func synchronizeResources_returns_false_for_hasNextPage_when_number_of_retrieved_results_is_zero() async throws { + // Given + remote.whenFetchingResources(thenReturn: .success([])) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote, + ordersRemote: ordersRemote) + + // When + let result = await withCheckedContinuation { continuation in + store.onAction(BookingAction.synchronizeResources(siteID: sampleSiteID, + pageNumber: defaultPageNumber, + pageSize: defaultPageSize, + onCompletion: { result in + continuation.resume(returning: result) + })) + } + + // Then + let hasNextPage = try result.get() + #expect(hasNextPage == false) + } + + @Test func synchronizeResources_returns_true_for_hasNextPage_when_number_of_retrieved_results_equals_pageSize() async throws { + // Given + let resources = Array(repeating: BookingResource.fake(), count: defaultPageSize) + remote.whenFetchingResources(thenReturn: .success(resources)) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote, + ordersRemote: ordersRemote) + + // When + let result = await withCheckedContinuation { continuation in + store.onAction(BookingAction.synchronizeResources(siteID: sampleSiteID, + pageNumber: defaultPageNumber, + pageSize: defaultPageSize, + onCompletion: { result in + continuation.resume(returning: result) + })) + } + + // Then + let hasNextPage = try result.get() + #expect(hasNextPage == true) + } + + @Test func synchronizeResources_stores_resources_upon_success() async throws { + // Given + let resource = BookingResource.fake().copy(siteID: sampleSiteID, resourceID: 123) + remote.whenFetchingResources(thenReturn: .success([resource])) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote, + ordersRemote: ordersRemote) + #expect(storedBookingResourceCount == 0) + + // When + let result = await withCheckedContinuation { continuation in + store.onAction(BookingAction.synchronizeResources(siteID: sampleSiteID, + pageNumber: defaultPageNumber, + pageSize: defaultPageSize, + onCompletion: { result in + continuation.resume(returning: result) + })) + } + + // Then + #expect(result.isSuccess) + #expect(storedBookingResourceCount == 1) + } + + @Test func synchronizeResources_updates_existing_resource_when_resource_already_exists() async throws { + // Given + let originalResource = BookingResource.fake().copy(siteID: sampleSiteID, resourceID: 123, name: "Original Name") + storeBookingResource(originalResource) + #expect(storedBookingResourceCount == 1) + + let updatedResource = originalResource.copy(name: "Updated Name") + remote.whenFetchingResources(thenReturn: .success([updatedResource])) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote, + ordersRemote: ordersRemote) + + // When + let result = await withCheckedContinuation { continuation in + store.onAction(BookingAction.synchronizeResources(siteID: sampleSiteID, + pageNumber: defaultPageNumber, + pageSize: defaultPageSize, + onCompletion: { result in + continuation.resume(returning: result) + })) + } + + // Then + #expect(result.isSuccess) + #expect(storedBookingResourceCount == 1) + let storedResource = try #require(viewStorage.loadBookingResource(siteID: sampleSiteID, resourceID: 123)) + #expect(storedResource.name == "Updated Name") + } + + @Test func synchronizeResources_stores_multiple_resources_upon_success() async throws { + // Given + let resource1 = BookingResource.fake().copy(siteID: sampleSiteID, resourceID: 123) + let resource2 = BookingResource.fake().copy(siteID: sampleSiteID, resourceID: 456) + remote.whenFetchingResources(thenReturn: .success([resource1, resource2])) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote, + ordersRemote: ordersRemote) + #expect(storedBookingResourceCount == 0) + + // When + let result = await withCheckedContinuation { continuation in + store.onAction(BookingAction.synchronizeResources(siteID: sampleSiteID, + pageNumber: defaultPageNumber, + pageSize: defaultPageSize, + onCompletion: { result in + continuation.resume(returning: result) + })) + } + + // Then + #expect(result.isSuccess) + #expect(storedBookingResourceCount == 2) + } + + @Test func synchronizeResources_returns_error_on_failure() async throws { + // Given + remote.whenFetchingResources(thenReturn: .failure(NetworkError.timeout())) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote, + ordersRemote: ordersRemote) + + // When + let result = await withCheckedContinuation { continuation in + store.onAction(BookingAction.synchronizeResources(siteID: sampleSiteID, + pageNumber: defaultPageNumber, + pageSize: defaultPageSize, + onCompletion: { result in + continuation.resume(returning: result) + })) + } + + // Then + #expect(result.isFailure) + let error = result.failure as? NetworkError + #expect(error == .timeout()) + } + + @Test func synchronizeResources_preserves_existing_resources_from_previous_pages() async throws { + // Given + let existingResource = BookingResource.fake().copy(siteID: sampleSiteID, resourceID: 999) + storeBookingResource(existingResource) + #expect(storedBookingResourceCount == 1) + + let newResource = BookingResource.fake().copy(siteID: sampleSiteID, resourceID: 123) + remote.whenFetchingResources(thenReturn: .success([newResource])) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote, + ordersRemote: ordersRemote) + + // When + let result = await withCheckedContinuation { continuation in + store.onAction(BookingAction.synchronizeResources(siteID: sampleSiteID, + pageNumber: defaultPageNumber, + pageSize: defaultPageSize, + onCompletion: { result in + continuation.resume(returning: result) + })) + } + + // Then + #expect(result.isSuccess) + #expect(storedBookingResourceCount == 2) + + // Verify both resources exist + let newStoredResource = try #require(viewStorage.loadBookingResource(siteID: sampleSiteID, resourceID: 123)) + #expect(newStoredResource.resourceID == 123) + + let existingStoredResource = try #require(viewStorage.loadBookingResource(siteID: sampleSiteID, resourceID: 999)) + #expect(existingStoredResource.resourceID == 999) + } + // MARK: - orderInfo Storage Tests @Test func synchronizeBookings_stores_complete_orderInfo_with_all_nested_properties() async throws { diff --git a/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift b/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift index 74b3c439a4e..6fefb18bb79 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift @@ -182,12 +182,9 @@ private extension BookingFiltersViewModel.BookingListFilter { extension BookingFiltersViewModel.BookingListFilter { func createViewModel(filters: BookingFiltersViewModel.Filters) -> FilterTypeViewModel { switch self { - case .teamMember: - // TODO: Implement team member selector when available - // For now, using static options with nil (Any option) - let options: [BookingResource?] = [nil] + case .teamMember(let siteID): return FilterTypeViewModel(title: title, - listSelectorConfig: .staticOptions(options: options), + listSelectorConfig: .bookingResource(siteID: siteID), selectedValue: filters.teamMember) case .product(let siteID): return FilterTypeViewModel(title: title, diff --git a/WooCommerce/Classes/Bookings/BookingFilters/ListSyncable.swift b/WooCommerce/Classes/Bookings/BookingFilters/ListSyncable.swift new file mode 100644 index 00000000000..3056fc0a39e --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingFilters/ListSyncable.swift @@ -0,0 +1,30 @@ +import Foundation +import Yosemite + +/// Protocol for configuring a list selector with different entity types. +/// Provides all necessary configuration for fetching, displaying, and syncing list items. +protocol ListSyncable { + associatedtype StorageType: ResultsControllerMutableType + associatedtype ModelType: Equatable & Hashable where ModelType == StorageType.ReadOnlyType + + var title: String { get } + var emptyStateMessage: String { get } + + // MARK: - ResultsController Configuration + + /// Creates the predicate for filtering storage objects + func createPredicate() -> NSPredicate + + /// Creates sort descriptors for ordering results + func createSortDescriptors() -> [NSSortDescriptor] + + // MARK: - Sync Configuration + + /// Creates the action to sync items from remote + func createSyncAction(pageNumber: Int, pageSize: Int, completion: @escaping (Result) -> Void) -> Action + + // MARK: - Display Configuration + + /// Returns the display name for an item + func displayName(for item: ModelType) -> String +} diff --git a/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift b/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift new file mode 100644 index 00000000000..63c6a3bfdf0 --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift @@ -0,0 +1,111 @@ +import SwiftUI + +struct SyncableListSelectorView: View { + @ObservedObject private var viewModel: SyncableListSelectorViewModel + @State var selectedItem: Syncable.ModelType? + + private let syncable: Syncable + private let onSelection: (Syncable.ModelType?) -> Void + + private let viewPadding: CGFloat = 16 + + init(viewModel: SyncableListSelectorViewModel, + syncable: Syncable, + selectedItem: Syncable.ModelType?, + onSelection: @escaping (Syncable.ModelType?) -> Void) { + self.viewModel = viewModel + self.syncable = syncable + self.selectedItem = selectedItem + self.onSelection = onSelection + } + + var body: some View { + VStack { + switch viewModel.syncState { + case .empty: + emptyStateView + case .syncingFirstPage: + loadingView + case .results: + itemList(with: viewModel.items, + onNextPage: { viewModel.onLoadNextPageAction() }) + } + } + .task { + viewModel.loadResources() + } + .navigationTitle(syncable.title) + .navigationBarTitleDisplayMode(.inline) + .onChange(of: selectedItem) { _, newValue in + onSelection(newValue) + } + } +} + +private extension SyncableListSelectorView { + var loadingView: some View { + VStack { + Spacer() + ProgressView().progressViewStyle(.circular) + Spacer() + } + .frame(maxWidth: .infinity) + .background(Color(.systemBackground)) + } + + func itemList(with items: [Syncable.ModelType], + onNextPage: @escaping () -> Void) -> some View { + List { + optionRow( + text: NSLocalizedString( + "listSelectorView.any", + value: "Any", + comment: "Option to select no filter on a list selector view" + ), + isSelected: selectedItem == nil, + onSelection: { selectedItem = nil } + ) + + ForEach(items, id: \.self) { item in + optionRow(text: syncable.displayName(for: item), + isSelected: item == selectedItem, + onSelection: { selectedItem = item }) + } + + InfiniteScrollIndicator(showContent: viewModel.shouldShowBottomActivityIndicator) + .padding(.top, viewPadding) + .onAppear { + onNextPage() + } + } + .listStyle(.plain) + .background(Color(.listBackground)) + } + + func optionRow(text: String, isSelected: Bool, onSelection: @escaping () -> Void) -> some View { + HStack { + Text(text) + Spacer() + Image(systemName: "checkmark") + .font(.body.weight(.medium)) + .foregroundStyle(Color.accentColor) + .renderedIf(isSelected) + } + .tappable { + onSelection() + } + .listRowBackground(Color(.listForeground(modal: false))) + } + + var emptyStateView: some View { + VStack { + Spacer() + Text(syncable.emptyStateMessage) + .secondaryBodyStyle() + Spacer() + } + .multilineTextAlignment(.center) + .padding(.horizontal, viewPadding) + .background(Color(.systemBackground)) + } +} diff --git a/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorViewModel.swift b/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorViewModel.swift new file mode 100644 index 00000000000..3011a63e043 --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorViewModel.swift @@ -0,0 +1,130 @@ +import Foundation +import Yosemite +import protocol Storage.StorageManagerType + +// Generic view model for syncable data list selector views +final class SyncableListSelectorViewModel: ObservableObject { + @Published private(set) var items: [Syncable.ModelType] = [] + + /// Keeps track of the current state of the syncing + @Published private(set) var syncState: SyncState = .empty + + /// Tracks if the infinite scroll indicator should be displayed. + @Published private(set) var shouldShowBottomActivityIndicator = false + + private let syncable: Syncable + private let stores: StoresManager + private let storage: StorageManagerType + + /// Supports infinite scroll. + private let paginationTracker: PaginationTracker + private let pageFirstIndex: Int = PaginationTracker.Defaults.pageFirstIndex + + /// ResultsController configured by the syncable + private lazy var resultsController: ResultsController = { + let predicate = syncable.createPredicate() + let sortDescriptors = syncable.createSortDescriptors() + return ResultsController( + storageManager: storage, + matching: predicate, + sortedBy: sortDescriptors + ) + }() + + init(syncable: Syncable, + stores: StoresManager = ServiceLocator.stores, + storage: StorageManagerType = ServiceLocator.storageManager) { + self.syncable = syncable + self.stores = stores + self.storage = storage + self.paginationTracker = PaginationTracker(pageFirstIndex: pageFirstIndex) + + configureResultsController() + configurePaginationTracker() + } + + /// Called when loading the first page of resources. + func loadResources() { + paginationTracker.syncFirstPage() + } + + /// Called when the next page should be loaded. + func onLoadNextPageAction() { + paginationTracker.ensureNextPageIsSynced() + } + + // MARK: - Private helper methods + + private func configurePaginationTracker() { + paginationTracker.delegate = self + } + + /// Performs initial fetch from storage and updates results. + private func configureResultsController() { + resultsController.onDidChangeContent = { [weak self] in + self?.updateResults() + } + resultsController.onDidResetContent = { [weak self] in + self?.updateResults() + } + do { + try resultsController.performFetch() + updateResults() + } catch { + ServiceLocator.crashLogging.logError(error) + } + } + + /// Updates row view models and sync state. + private func updateResults() { + items = resultsController.fetchedObjects + transitionToResultsUpdatedState() + } +} + +extension SyncableListSelectorViewModel: PaginationTrackerDelegate { + func sync(pageNumber: Int, pageSize: Int, reason: String?, onCompletion: SyncCompletion?) { + transitionToSyncingState() + let action = syncable.createSyncAction( + pageNumber: pageNumber, + pageSize: pageSize + ) { [weak self] result in + switch result { + case .success(let hasNextPage): + onCompletion?(.success(hasNextPage)) + + case .failure(let error): + DDLogError("⛔️ Error synchronizing: \(error)") + onCompletion?(.failure(error)) + } + + self?.updateResults() + } + stores.dispatch(action) + } +} + +// MARK: State Machine + +extension SyncableListSelectorViewModel { + /// Represents possible states for syncing items. + enum SyncState: Equatable { + case syncingFirstPage + case results + case empty + } + + /// Update states for sync from remote. + func transitionToSyncingState() { + shouldShowBottomActivityIndicator = true + if items.isEmpty { + syncState = .syncingFirstPage + } + } + + /// Update states after sync is complete. + func transitionToResultsUpdatedState() { + shouldShowBottomActivityIndicator = false + syncState = items.isNotEmpty ? .results : .empty + } +} diff --git a/WooCommerce/Classes/Bookings/BookingFilters/TeamMemberListSyncable.swift b/WooCommerce/Classes/Bookings/BookingFilters/TeamMemberListSyncable.swift new file mode 100644 index 00000000000..f6b30c48135 --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingFilters/TeamMemberListSyncable.swift @@ -0,0 +1,60 @@ +import Foundation +import Yosemite + +/// Syncable implementation for team member (booking resource) filtering +struct TeamMemberListSyncable: ListSyncable { + typealias StorageType = StorageBookingResource + typealias ModelType = BookingResource + + let siteID: Int64 + + var title: String { Localization.title } + + var emptyStateMessage: String { Localization.noMembersFound } + + // MARK: - ResultsController Configuration + + func createPredicate() -> NSPredicate { + NSPredicate(format: "siteID == %lld", siteID) + } + + func createSortDescriptors() -> [NSSortDescriptor] { + [NSSortDescriptor(key: "resourceID", ascending: false)] + } + + // MARK: - Sync Configuration + + func createSyncAction( + pageNumber: Int, + pageSize: Int, + completion: @escaping (Result) -> Void + ) -> Action { + BookingAction.synchronizeResources( + siteID: siteID, + pageNumber: pageNumber, + pageSize: pageSize, + onCompletion: completion + ) + } + + // MARK: - Display Configuration + + func displayName(for item: BookingResource) -> String { + item.name + } +} + +private extension TeamMemberListSyncable { + enum Localization { + static let title = NSLocalizedString( + "bookingTeamMemberSelectorView.title", + value: "Team member", + comment: "Title of the booking team member selector view" + ) + static let noMembersFound = NSLocalizedString( + "bookingTeamMemberSelectorView.noMembersFound", + value: "No team members found", + comment: "Text on the empty view of the booking team member selector view" + ) + } +} diff --git a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift index 1f5eec3e979..0a907439ed3 100644 --- a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift @@ -1,4 +1,5 @@ import Combine +import SwiftUI import UIKit import Yosemite @@ -93,6 +94,8 @@ enum FilterListValueSelectorConfig { case products(siteID: Int64) // Filter list selector for customer case customer(siteID: Int64) + // Filter list selector for booking team member + case bookingResource(siteID: Int64) } @@ -358,6 +361,23 @@ private extension FilterListViewController { WooNavigationController(rootViewController: controller), animated: true ) + case .bookingResource(let siteID): + let selectedMember = selected.selectedValue as? BookingResource + let syncable = TeamMemberListSyncable(siteID: siteID) + let viewModel = SyncableListSelectorViewModel(syncable: syncable) + let memberListSelectorView = SyncableListSelectorView( + viewModel: viewModel, + syncable: syncable, + selectedItem: selectedMember, + onSelection: { [weak self] resource in + selected.selectedValue = resource + self?.updateUI(numberOfActiveFilters: self?.viewModel.filterTypeViewModels.numberOfActiveFilters ?? 0) + self?.listSelector.reloadData() + self?.listSelector.navigationController?.popViewController(animated: true) + } + ) + let hostingController = UIHostingController(rootView: memberListSelectorView) + listSelector.navigationController?.pushViewController(hostingController, animated: true) } } }