diff --git a/Modules/Sources/Networking/Remote/BookingsRemote.swift b/Modules/Sources/Networking/Remote/BookingsRemote.swift index d09167f68a7..e04c608b252 100644 --- a/Modules/Sources/Networking/Remote/BookingsRemote.swift +++ b/Modules/Sources/Networking/Remote/BookingsRemote.swift @@ -20,7 +20,8 @@ public protocol BookingsRemoteProtocol { from siteID: Int64, bookingID: Int64, attendanceStatus: BookingAttendanceStatus?, - bookingStatus: BookingStatus? + bookingStatus: BookingStatus?, + note: String? ) async throws -> Booking? func fetchResource(resourceID: Int64, @@ -152,7 +153,8 @@ public final class BookingsRemote: Remote, BookingsRemoteProtocol { from siteID: Int64, bookingID: Int64, attendanceStatus: BookingAttendanceStatus?, - bookingStatus: BookingStatus? + bookingStatus: BookingStatus?, + note: String? ) async throws -> Booking? { let path = "\(Path.bookings)/\(bookingID)" var parameters: [String: String] = [:] @@ -165,6 +167,10 @@ public final class BookingsRemote: Remote, BookingsRemoteProtocol { parameters[ParameterKey.status] = bookingStatus.rawValue } + if let note { + parameters[ParameterKey.note] = note + } + let request = JetpackRequest( wooApiVersion: .wcBookings, method: .put, @@ -259,5 +265,6 @@ public extension BookingsRemote { static let attendanceStatus = "attendance_status" static let paymentStatus = "booking_status" // to be updated later when payment filtering is supported static let status: String = "status" + static let note: String = "note" } } diff --git a/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift b/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift index e1f99108ffb..8808506a506 100644 --- a/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift +++ b/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift @@ -617,7 +617,6 @@ public enum WooAnalyticsStat: String { case interacRefundCanceled = "interac_refund_cancelled" // MARK: Push Notifications Events - // case pushNotificationReceived = "push_notification_received" case pushNotificationAlertPressed = "push_notification_alert_pressed" case pushNotificationOSAlertAllowed = "push_notification_os_alert_allowed" diff --git a/Modules/Sources/Yosemite/Actions/BookingAction.swift b/Modules/Sources/Yosemite/Actions/BookingAction.swift index 50e7f65489f..5f710654e74 100644 --- a/Modules/Sources/Yosemite/Actions/BookingAction.swift +++ b/Modules/Sources/Yosemite/Actions/BookingAction.swift @@ -91,4 +91,16 @@ public enum BookingAction: Action { case markBookingAsPaid(siteID: Int64, bookingID: Int64, onCompletion: (Error?) -> Void) + + /// Updates a booking note. + /// + /// - Parameter siteID: The site ID of the booking. + /// - Parameter bookingID: The ID of the booking to be updated. + /// - Parameter note: The new note. + /// - Parameter onCompletion: called when update completes, returns an error in case of a failure. + /// + case updateBookingNote(siteID: Int64, + bookingID: Int64, + note: String, + onCompletion: (Error?) -> Void) } diff --git a/Modules/Sources/Yosemite/Stores/BookingStore.swift b/Modules/Sources/Yosemite/Stores/BookingStore.swift index 2871dacada2..4753075c4b2 100644 --- a/Modules/Sources/Yosemite/Stores/BookingStore.swift +++ b/Modules/Sources/Yosemite/Stores/BookingStore.swift @@ -82,6 +82,13 @@ public class BookingStore: Store { bookingID: bookingID, onCompletion: onCompletion ) + case .updateBookingNote(let siteID, let bookingID, let note, let onCompletion): + updateBookingNote( + siteID: siteID, + bookingID: bookingID, + note: note, + onCompletion: onCompletion + ) } } } @@ -308,6 +315,7 @@ private extension BookingStore { bookingID: bookingID, attendanceStatus: status, bookingStatus: nil, + note: nil, ) { await self.upsertStoredBookingsInBackground( readOnlyBookings: [remoteBooking], @@ -333,6 +341,37 @@ private extension BookingStore { } } + func updateBookingNote( + siteID: Int64, + bookingID: Int64, + note: String, + onCompletion: @escaping (Error?) -> Void + ) { + Task { @MainActor in + do { + if let remoteBooking = try await self.remote.updateBooking( + from: siteID, + bookingID: bookingID, + attendanceStatus: nil, + bookingStatus: nil, + note: note, + ) { + await self.upsertStoredBookingsInBackground( + readOnlyBookings: [remoteBooking], + readOnlyOrders: [], + siteID: siteID + ) + + onCompletion(nil) + } else { + return onCompletion(UpdateBookingStatusError.missingRemoteBooking) + } + } catch { + return onCompletion(error) + } + } + } + /// Updates local (Storage) Booking attendance status func updateBookingAttendanceStatusLocally( siteID: Int64, @@ -373,7 +412,8 @@ private extension BookingStore { from: siteID, bookingID: bookingID, attendanceStatus: nil, - bookingStatus: .cancelled + bookingStatus: .cancelled, + note: nil, ) { await upsertStoredBookingsInBackground( readOnlyBookings: [remoteBooking], @@ -403,7 +443,8 @@ private extension BookingStore { from: siteID, bookingID: bookingID, attendanceStatus: nil, - bookingStatus: .paid + bookingStatus: .paid, + note: nil, ) { await upsertStoredBookingsInBackground( readOnlyBookings: [remoteBooking], diff --git a/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift index 51bcee8b878..612915e898b 100644 --- a/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift @@ -137,7 +137,8 @@ struct BookingsRemoteTests { from: sampleSiteID, bookingID: bookingID, attendanceStatus: .noShow, - bookingStatus: nil + bookingStatus: nil, + note: nil ) // Then @@ -157,7 +158,8 @@ struct BookingsRemoteTests { from: sampleSiteID, bookingID: bookingID, attendanceStatus: .noShow, - bookingStatus: nil + bookingStatus: nil, + note: nil ) // Then @@ -179,7 +181,8 @@ struct BookingsRemoteTests { from: sampleSiteID, bookingID: bookingID, attendanceStatus: nil, - bookingStatus: .confirmed + bookingStatus: .confirmed, + note: nil ) // Then @@ -201,7 +204,8 @@ struct BookingsRemoteTests { from: sampleSiteID, bookingID: bookingID, attendanceStatus: .booked, - bookingStatus: .paid + bookingStatus: .paid, + note: nil ) // Then @@ -254,4 +258,28 @@ struct BookingsRemoteTests { #expect((parameters["page"] as? String) == "3") #expect((parameters["per_page"] as? String) == "100") } + + @Test func test_updateBookingNote_sends_correct_parameters_for_booking_note() 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: nil, + note: "hello" + ) + + // Then + let request = try #require(network.requestsForResponseData.first as? JetpackRequest) + let parameters = request.parameters + + #expect(parameters["attendance_status"] == nil) + #expect(parameters["status"] == nil) + #expect((parameters["note"] as? String) == "hello") + } } diff --git a/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift index 97032aff546..3e1522e6fd2 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift @@ -9,6 +9,7 @@ final class MockBookingsRemote: BookingsRemoteProtocol { private var fetchResourceResult: Result? private var updateBookingResult: Result? private var fetchResourcesResult: Result<[BookingResource], Error>? + private var updateBookingNote: Result? func whenLoadingAllBookings(thenReturn result: Result<[Booking], Error>) { loadAllBookingsResult = result @@ -59,7 +60,8 @@ final class MockBookingsRemote: BookingsRemoteProtocol { func updateBooking(from siteID: Int64, bookingID: Int64, attendanceStatus: BookingAttendanceStatus?, - bookingStatus: BookingStatus?) async throws -> Booking? { + bookingStatus: BookingStatus?, + note: String?) async throws -> Booking? { guard let result = updateBookingResult else { throw NetworkError.timeout() } diff --git a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift index ed57e0f57b1..248389bd3fb 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift @@ -242,27 +242,57 @@ extension BookingDetailsViewModel { ) { [weak self] error in if let error, let self { DDLogError("⛔️ Error updating booking attendance status: \(error)") - displayAttendanceStatusUpdatedErrorNotice(status: newStatus) + displayErrorNotice( + messageFormat: Localization.bookingAttendanceStatusUpdateFailedMessage + ) { [weak self] in + self?.updateAttendanceStatus(to: newStatus) + } } } stores.dispatch(action) } - private func displayAttendanceStatusUpdatedErrorNotice(status: BookingAttendanceStatus) { + @MainActor + func updateNote(to newNote: String) async -> MultilineCommitResult { + await withCheckedContinuation { continuation in + let action = BookingAction.updateBookingNote( + siteID: booking.siteID, + bookingID: booking.bookingID, + note: newNote + ) { [booking] error in + if let error { + DDLogError("⛔️ Error updating booking note: \(error)") + let message = String.localizedStringWithFormat( + Localization.bookingNoteUpdateFailedMessage, + booking.bookingID + ) + + continuation.resume(returning: .failure(message: message)) + return + } + + continuation.resume(returning: .success) + } + + stores.dispatch(action) + } + } + + private func displayErrorNotice( + messageFormat: String, + retry: @escaping () -> Void + ) { let text = String.localizedStringWithFormat( - Localization.bookingAttendanceStatusUpdateFailedMessage, + messageFormat, booking.bookingID ) - self.notice = Notice( + + notice = Notice( message: text, feedbackType: .error, actionTitle: Localization.retryActionTitle - ) { [weak self] in - guard let self else { - return - } - - updateAttendanceStatus(to: status) + ) { + retry() } } } @@ -475,6 +505,14 @@ private extension BookingDetailsViewModel { + "Parameters: %1$d - Booking number" ) + static let bookingNoteUpdateFailedMessage = NSLocalizedString( + "BookingDetailsView.bookingNote.failureMessage.", + value: "Unable to update note of Booking #%1$d.", + comment: "Content of error presented when updating the not of a Booking fails. " + + "It reads: Unable to update note of Booking #{Booking number}. " + + "Parameters: %1$d - Booking number" + ) + static let bookingCancellationFailedMessage = NSLocalizedString( "BookingDetailsView.cancellation.failureMessage", value: "Unable to cancel Booking #%1$d.", diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift index f43706159b9..dfb3d063fee 100644 --- a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift @@ -244,7 +244,9 @@ private extension BookingDetailsView { func bookingNotesView() -> some View { MultilineEditableTextRow(value: viewModel.note, placeholder: Localization.bookingNotesRowText, - detailTitle: Localization.bookingNoteNavbarText) + detailTitle: Localization.bookingNoteNavbarText) { newNote in + return await viewModel.updateNote(to: newNote) + } } } diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextDetailView.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextDetailView.swift index 9bc920d5513..b7dd0a61f3d 100644 --- a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextDetailView.swift +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextDetailView.swift @@ -1,5 +1,10 @@ import SwiftUI +enum MultilineCommitResult { + case success + case failure(message: String) +} + struct MultilineEditableTextDetailView: View { @Environment(\.dismiss) private var dismiss @@ -7,13 +12,17 @@ struct MultilineEditableTextDetailView: View { @State private var editedText: String @State private var showDiscardChangesDialog = false @FocusState private var isFocused: Bool + @State private var notice: Notice? + @State private var isSaving = false let title: String? + let onCommit: (String) async -> MultilineCommitResult - init(text: Binding, title: String? = nil) { + init(text: Binding, title: String? = nil, onCommit: @escaping (String) async -> MultilineCommitResult) { self._text = text self._editedText = State(initialValue: text.wrappedValue) self.title = title + self.onCommit = onCommit } var body: some View { @@ -29,6 +38,7 @@ struct MultilineEditableTextDetailView: View { .toolbar { toolbar } .wooNavigationBarStyle() .onAppear { isFocused = true } + .notice($notice) } private var toolbar: some ToolbarContent { @@ -52,16 +62,42 @@ struct MultilineEditableTextDetailView: View { if editedText != text { ToolbarItem(placement: .primaryAction) { - Button(Localization.doneButtonTitle) { - text = editedText - dismiss() + if isSaving { + ProgressView() + } else { + Button(Localization.doneButtonTitle) { + Task { + await handleDoneTapped() + } + } + .fontWeight(.medium) + .disabled(isSaving) } - .fontWeight(.medium) } } } } + private func handleDoneTapped() async { + guard !isSaving else { return } + + isSaving = true + let newText = editedText + switch await onCommit(newText) { + case .success: + text = newText + isSaving = false + dismiss() + case .failure(let message): + isSaving = false + + notice = Notice( + message: message, + feedbackType: .error, + ) + } + } + private func handleBackButtonTap() { if editedText != text { showDiscardChangesDialog = true diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextRow.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextRow.swift index a42fe8f7f62..febca79f970 100644 --- a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextRow.swift +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextRow.swift @@ -4,16 +4,22 @@ struct MultilineEditableTextRow: View { @State var value: String let placeholder: String let detailTitle: String? + let onCommit: (String) async -> MultilineCommitResult - init(value: String, placeholder: String, detailTitle: String? = nil) { - self.value = value + init(value: String, + placeholder: String, + detailTitle: String? = nil, + onCommit: @escaping (String) async -> MultilineCommitResult + ) { + self._value = State(initialValue: value) self.placeholder = placeholder self.detailTitle = detailTitle + self.onCommit = onCommit } var body: some View { NavigationLink { - MultilineEditableTextDetailView(text: $value, title: detailTitle) + MultilineEditableTextDetailView(text: $value, title: detailTitle, onCommit: onCommit) } label: { content } @@ -58,7 +64,9 @@ fileprivate extension MultilineEditableTextRow { @Previewable @State var text: String = "" NavigationStack { - MultilineEditableTextRow(value: text, placeholder: "Add note") + MultilineEditableTextRow(value: text, placeholder: "Add note") { _ in + return .success + } .padding(.horizontal, 16) } .preferredColorScheme(.dark)