diff --git a/Modules/Sources/Networking/Remote/BookingsRemote.swift b/Modules/Sources/Networking/Remote/BookingsRemote.swift index 84b3afa86d5..f72f842dec7 100644 --- a/Modules/Sources/Networking/Remote/BookingsRemote.swift +++ b/Modules/Sources/Networking/Remote/BookingsRemote.swift @@ -19,7 +19,8 @@ public protocol BookingsRemoteProtocol { func updateBooking( from siteID: Int64, bookingID: Int64, - attendanceStatus: BookingAttendanceStatus + attendanceStatus: BookingAttendanceStatus?, + bookingStatus: BookingStatus? ) async throws -> Booking? func fetchResource(resourceID: Int64, @@ -150,12 +151,20 @@ public final class BookingsRemote: Remote, BookingsRemoteProtocol { public func updateBooking( from siteID: Int64, bookingID: Int64, - attendanceStatus: BookingAttendanceStatus + attendanceStatus: BookingAttendanceStatus?, + bookingStatus: BookingStatus? ) async throws -> Booking? { let path = "\(Path.bookings)/\(bookingID)" - let parameters = [ - ParameterKey.attendanceStatus: attendanceStatus.rawValue - ] + var parameters: [String: String] = [:] + + if let attendanceStatus { + parameters[ParameterKey.attendanceStatus] = attendanceStatus.rawValue + } + + if let bookingStatus { + parameters[ParameterKey.status] = bookingStatus.rawValue + } + let request = JetpackRequest( wooApiVersion: .wcBookings, method: .put, @@ -249,5 +258,6 @@ public extension BookingsRemote { static let resource: String = "resource" static let bookingStatus: String = "booking_status" static let attendanceStatus = "attendance_status" + static let status: String = "status" } } diff --git a/Modules/Sources/Yosemite/Actions/BookingAction.swift b/Modules/Sources/Yosemite/Actions/BookingAction.swift index b6b71a33899..bac676ce952 100644 --- a/Modules/Sources/Yosemite/Actions/BookingAction.swift +++ b/Modules/Sources/Yosemite/Actions/BookingAction.swift @@ -71,4 +71,14 @@ public enum BookingAction: Action { bookingID: Int64, status: BookingAttendanceStatus, onCompletion: (Error?) -> Void) + + /// Cancels a booking by updating its status to cancelled. + /// + /// - Parameter siteID: The site ID of the booking. + /// - Parameter bookingID: The ID of the booking to be cancelled. + /// - Parameter onCompletion: called when cancellation completes, returns an error in case of a failure. + /// + case cancelBooking(siteID: Int64, + bookingID: Int64, + onCompletion: (Error?) -> Void) } diff --git a/Modules/Sources/Yosemite/Stores/BookingStore.swift b/Modules/Sources/Yosemite/Stores/BookingStore.swift index 5de04372d5a..ae609945cb8 100644 --- a/Modules/Sources/Yosemite/Stores/BookingStore.swift +++ b/Modules/Sources/Yosemite/Stores/BookingStore.swift @@ -70,6 +70,12 @@ public class BookingStore: Store { status: status, onCompletion: onCompletion ) + case .cancelBooking(let siteID, let bookingID, let onCompletion): + cancelBooking( + siteID: siteID, + bookingID: bookingID, + onCompletion: onCompletion + ) } } } @@ -294,7 +300,8 @@ private extension BookingStore { if let remoteBooking = try await self.remote.updateBooking( from: siteID, bookingID: bookingID, - attendanceStatus: status + attendanceStatus: status, + bookingStatus: nil, ) { await self.upsertStoredBookingsInBackground( readOnlyBookings: [remoteBooking], @@ -347,6 +354,36 @@ private extension BookingStore { } }, on: .main) } + + /// Cancels a booking by updating its status to cancelled. + func cancelBooking( + siteID: Int64, + bookingID: Int64, + onCompletion: @escaping (Error?) -> Void + ) { + Task { @MainActor in + do { + if let remoteBooking = try await remote.updateBooking( + from: siteID, + bookingID: bookingID, + attendanceStatus: nil, + bookingStatus: .cancelled + ) { + await upsertStoredBookingsInBackground( + readOnlyBookings: [remoteBooking], + readOnlyOrders: [], + siteID: siteID + ) + + onCompletion(nil) + } else { + return onCompletion(UpdateBookingStatusError.missingRemoteBooking) + } + } catch { + onCompletion(error) + } + } + } } diff --git a/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift index 621d7647a0f..51bcee8b878 100644 --- a/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift @@ -137,6 +137,7 @@ struct BookingsRemoteTests { from: sampleSiteID, bookingID: bookingID, attendanceStatus: .noShow, + bookingStatus: nil ) // Then @@ -145,6 +146,72 @@ struct BookingsRemoteTests { #expect(booking?.id == bookingID) } + @Test func test_updateBooking_sends_correct_parameters_for_attendance_status() async throws { + // Given + let remote = BookingsRemote(network: network) + let bookingID: Int64 = 206 + network.simulateResponse(requestUrlSuffix: "bookings/\(bookingID)", filename: "booking-no-create-update-dates") + + // When + _ = try await remote.updateBooking( + from: sampleSiteID, + bookingID: bookingID, + attendanceStatus: .noShow, + bookingStatus: nil + ) + + // Then + let request = try #require(network.requestsForResponseData.first as? JetpackRequest) + let parameters = request.parameters + + #expect((parameters["attendance_status"] as? String) == "no-show") + #expect(parameters["status"] == nil) + } + + @Test func test_updateBooking_sends_correct_parameters_for_booking_status() async throws { + // Given + let remote = BookingsRemote(network: network) + let bookingID: Int64 = 206 + network.simulateResponse(requestUrlSuffix: "bookings/\(bookingID)", filename: "booking-no-create-update-dates") + + // When + _ = try await remote.updateBooking( + from: sampleSiteID, + bookingID: bookingID, + attendanceStatus: nil, + bookingStatus: .confirmed + ) + + // Then + let request = try #require(network.requestsForResponseData.first as? JetpackRequest) + let parameters = request.parameters + + #expect(parameters["attendance_status"] == nil) + #expect((parameters["status"] as? String) == "confirmed") + } + + @Test func test_updateBooking_sends_correct_parameters_for_both_statuses() async throws { + // Given + let remote = BookingsRemote(network: network) + let bookingID: Int64 = 206 + network.simulateResponse(requestUrlSuffix: "bookings/\(bookingID)", filename: "booking-no-create-update-dates") + + // When + _ = try await remote.updateBooking( + from: sampleSiteID, + bookingID: bookingID, + attendanceStatus: .booked, + bookingStatus: .paid + ) + + // Then + let request = try #require(network.requestsForResponseData.first as? JetpackRequest) + let parameters = request.parameters + + #expect((parameters["attendance_status"] as? String) == "booked") + #expect((parameters["status"] as? String) == "paid") + } + @Test func test_fetchResources_properly_returns_parsed_resources() async throws { // Given let remote = BookingsRemote(network: network) diff --git a/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift index 8099f54188d..97032aff546 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift @@ -42,28 +42,31 @@ final class MockBookingsRemote: BookingsRemoteProtocol { return try result.get() } - func loadBooking(bookingID: Int64, siteID: Int64) async throws -> Networking.Booking? { + func loadBooking(bookingID: Int64, siteID: Int64) async throws -> Booking? { guard let result = loadBookingResult else { throw NetworkError.timeout() } return try result.get() } - func fetchResource(resourceID: Int64, siteID: Int64) async throws -> Networking.BookingResource? { + func fetchResource(resourceID: Int64, siteID: Int64) async throws -> BookingResource? { guard let result = fetchResourceResult else { throw NetworkError.timeout() } return try result.get() } - func updateBooking(from siteID: Int64, bookingID: Int64, attendanceStatus: Networking.BookingAttendanceStatus) async throws -> Networking.Booking? { + func updateBooking(from siteID: Int64, + bookingID: Int64, + attendanceStatus: BookingAttendanceStatus?, + bookingStatus: BookingStatus?) async throws -> Booking? { guard let result = updateBookingResult else { throw NetworkError.timeout() } return try result.get() } - func fetchResources(for siteID: Int64, pageNumber: Int, pageSize: Int) async throws -> [Networking.BookingResource] { + func fetchResources(for siteID: Int64, pageNumber: Int, pageSize: Int) async throws -> [BookingResource] { guard let result = fetchResourcesResult else { throw NetworkError.timeout() } diff --git a/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift b/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift index 7127e1455e1..102eac48bf4 100644 --- a/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift +++ b/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift @@ -743,6 +743,112 @@ struct BookingStoreTests { #expect(storedBooking.attendanceStatusKey == BookingAttendanceStatus.booked.rawValue) } + // MARK: - cancelBooking + + @Test func cancelBooking_updates_local_booking_to_cancelled_status() async throws { + // Given + let booking = Booking.fake().copy( + siteID: sampleSiteID, + bookingID: 1, + statusKey: "confirmed" + ) + storeBooking(booking) + + let cancelledBooking = booking.copy(statusKey: "cancelled") + remote.whenUpdatingBooking(thenReturn: .success(cancelledBooking)) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote, + ordersRemote: ordersRemote) + + // When + let error = await withCheckedContinuation { continuation in + store.onAction( + BookingAction.cancelBooking( + siteID: sampleSiteID, + bookingID: 1, + onCompletion: { error in + continuation.resume(returning: error) + } + ) + ) + } + + // Then + #expect(error == nil) + let storedBooking = try #require(viewStorage.loadBooking(siteID: sampleSiteID, bookingID: 1)) + #expect(storedBooking.statusKey == "cancelled") + } + + @Test func cancelBooking_returns_error_on_remote_failure() async throws { + // Given + let booking = Booking.fake().copy( + siteID: sampleSiteID, + bookingID: 1, + statusKey: "confirmed" + ) + storeBooking(booking) + + remote.whenUpdatingBooking(thenReturn: .failure(NetworkError.timeout())) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote, + ordersRemote: ordersRemote) + + // When + let error = await withCheckedContinuation { continuation in + store.onAction( + BookingAction.cancelBooking( + siteID: sampleSiteID, + bookingID: 1, + onCompletion: { error in + continuation.resume(returning: error) + } + ) + ) + } + + // Then + #expect(error != nil) + let networkError = error as? NetworkError + #expect(networkError == .timeout()) + } + + @Test func cancelBooking_returns_error_when_remote_booking_is_missing() async throws { + // Given + let booking = Booking.fake().copy( + siteID: sampleSiteID, + bookingID: 1, + statusKey: "confirmed" + ) + storeBooking(booking) + + remote.whenUpdatingBooking(thenReturn: .success(nil)) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote, + ordersRemote: ordersRemote) + + // When + let error = await withCheckedContinuation { continuation in + store.onAction( + BookingAction.cancelBooking( + siteID: sampleSiteID, + bookingID: 1, + onCompletion: { error in + continuation.resume(returning: error) + } + ) + ) + } + + // Then + #expect(error != nil) + } + // MARK: - synchronizeResources @Test func synchronizeResources_returns_false_for_hasNextPage_when_number_of_retrieved_results_is_zero() async throws { diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift index 8c23939b87c..0823027dc3b 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift @@ -90,6 +90,7 @@ private extension BookingListView { var loadingView: some View { VStack { + header Spacer() ProgressView().progressViewStyle(.circular) Spacer() @@ -158,7 +159,8 @@ private extension BookingListView { LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) { Section { emptyStateContent(isSearching: isSearching) - .frame(minWidth: proxy.size.width, minHeight: proxy.size.height) + .frame(minWidth: proxy.size.width, + minHeight: proxy.size.height - BookingListViewLayout.defaultHeaderHeight * scale) } header: { header } @@ -233,6 +235,7 @@ fileprivate enum BookingListViewLayout { static let emptyStatePadding: CGFloat = 24 static let emptyStateImageWidth: CGFloat = 67 static let cornerRadius: CGFloat = 8 + static let defaultHeaderHeight: CGFloat = 98 } fileprivate enum BookingListViewLocalization { diff --git a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift index e26793b0ea5..6f5db8d63a7 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift @@ -262,6 +262,39 @@ extension BookingDetailsViewModel { } } +/// Cancel booking +extension BookingDetailsViewModel { + var isBookingCancellable: Bool { + let ineligibleStatuses: [BookingStatus] = [.cancelled, .complete, .unknown] + return !ineligibleStatuses.contains(booking.bookingStatus) + } + + @MainActor + func cancelBooking() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + stores.dispatch(BookingAction.cancelBooking(siteID: booking.siteID, bookingID: booking.bookingID) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + }) + } + } + + func displayBookingCancellationErrorNotice(onRetry: @escaping () -> Void) { + let text = String.localizedStringWithFormat( + Localization.bookingCancellationFailedMessage, + booking.bookingID + ) + self.notice = Notice( + message: text, + feedbackType: .error, + actionTitle: Localization.retryActionTitle + ) { onRetry() } + } +} + private extension BookingDetailsViewModel { @MainActor func fetchResource() async -> BookingResource? { @@ -302,22 +335,17 @@ private extension BookingDetailsViewModel { extension BookingDetailsViewModel { var cancellationAlertMessage: String { - let productName = booking.orderInfo?.productInfo?.name ?? "" + let productName = booking.productName ?? "" + let customerName = booking.customerName - let customerName: String = { - guard let address = booking.orderInfo?.customerInfo?.billingAddress else { - return "" - } - return [address.firstName, address.lastName] - .compactMap { $0 } - .joined(separator: " ") - }() + guard productName.isNotEmpty, customerName.isNotEmpty else { + return Localization.cancelBookingAlertGenericMessage + } let date = booking.startDate.formatted( date: .long, time: .shortened ) - return String( format: Localization.cancelBookingAlertMessage, customerName, @@ -390,6 +418,12 @@ private extension BookingDetailsViewModel { comment: "Message for the booking cancellation confirmation alert. %1$@ is customer name, %2$@ is product name, %3$@ is booking date." ) + static let cancelBookingAlertGenericMessage = NSLocalizedString( + "BookingDetailsView.cancelation.alert.genericMessage", + value: "Are you sure you want to cancel this booking?", + comment: "Generic message for the booking cancellation confirmation alert." + ) + static let bookingAttendanceStatusUpdateFailedMessage = NSLocalizedString( "BookingDetailsView.attendanceStatus.updateFailed.message", value: "Unable to change attendance status of Booking #%1$d", @@ -398,6 +432,14 @@ private extension BookingDetailsViewModel { + "Parameters: %1$d - Booking number" ) + static let bookingCancellationFailedMessage = NSLocalizedString( + "BookingDetailsView.cancellation.failureMessage", + value: "Unable to cancel Booking #%1$d", + comment: "Content of error presented when cancelling a Booking fails. " + + "It reads: Unable cancel Booking #{Booking number}. " + + "Parameters: %1$d - Booking number" + ) + static let retryActionTitle = NSLocalizedString( "BookingDetailsView.retry.action", value: "Retry", diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift index 6191a146fda..520912cd7b6 100644 --- a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift @@ -7,6 +7,7 @@ struct BookingDetailsView: View { @State private var showingOptions = false @State private var showingStatusSheet = false @State private var showingCancelAlert = false + @State private var cancellingBooking = false @State private var notice: Notice? @ObservedObject private var viewModel: BookingDetailsViewModel @@ -67,8 +68,9 @@ struct BookingDetailsView: View { viewModel.navigateToOrderDetails() } Button(Localization.cancelBookingAction, role: .destructive) { - print("On cancel booking tap") + showingCancelAlert = true } + .renderedIf(viewModel.isBookingCancellable) } } } @@ -91,7 +93,7 @@ struct BookingDetailsView: View { ) { Button(Localization.cancelBookingAlertCancelAction, role: .cancel) {} Button(Localization.cancelBookingAlertConfirmAction, role: .destructive) { - print("On cancel booking confirmation tap") + cancelBooking() } } message: { Text(viewModel.cancellationAlertMessage) @@ -187,8 +189,9 @@ private extension BookingDetailsView { } label: { Text(Localization.cancelBooking) } - .buttonStyle(SecondaryButtonStyle()) + .buttonStyle(SecondaryLoadingButtonStyle(isLoading: cancellingBooking)) .padding(.vertical, Layout.contentVerticalPadding) + .renderedIf(viewModel.isBookingCancellable) } } @@ -254,6 +257,20 @@ private extension BookingDetailsView { } } +extension BookingDetailsView { + func cancelBooking() { + Task { @MainActor in + cancellingBooking = true + do { + try await viewModel.cancelBooking() + } catch { + viewModel.displayBookingCancellationErrorNotice(onRetry: cancelBooking) + } + cancellingBooking = false + } + } +} + extension BookingDetailsView { enum Localization { static let markAsPaid = NSLocalizedString(