From 0cac69aaa019e0af39063af5ed514a7108e5493c Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Mon, 17 Nov 2025 10:13:43 +0100 Subject: [PATCH 1/6] Make network call --- .../Networking/Remote/BookingsRemote.swift | 11 ++- .../Analytics/WooAnalyticsStat.swift | 1 - .../Yosemite/Actions/BookingAction.swift | 12 +++ .../Yosemite/Stores/BookingStore.swift | 89 ++++++++++++++++++- .../BookingDetailsViewModel.swift | 14 +++ .../Booking Details/BookingDetailsView.swift | 4 +- .../MultilineEditableTextDetailView.swift | 5 +- .../MultilineEditableTextRow.swift | 12 ++- 8 files changed, 138 insertions(+), 10 deletions(-) diff --git a/Modules/Sources/Networking/Remote/BookingsRemote.swift b/Modules/Sources/Networking/Remote/BookingsRemote.swift index d09167f68a7..81c50d670a1 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..4a1901d5f53 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 attendance status. + /// + /// - 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..af7a81c1230 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,54 @@ private extension BookingStore { } } + func updateBookingNote( + siteID: Int64, + bookingID: Int64, + note: String, + onCompletion: @escaping (Error?) -> Void + ) { + updateBookingNoteLocally( + siteID: siteID, + bookingID: bookingID, + note: note + ) { [weak self] previousNote in + guard let self else { + return onCompletion(UpdateBookingStatusError.undefinedState) + } + + 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 { + /// Revert Optimistic Update + self.updateBookingNoteLocally( + siteID: siteID, + bookingID: bookingID, + note: note + ) { _ in + onCompletion(error) + } + } + } + } + } + /// Updates local (Storage) Booking attendance status func updateBookingAttendanceStatusLocally( siteID: Int64, @@ -361,6 +417,33 @@ private extension BookingStore { }, on: .main) } + func updateBookingNoteLocally( + siteID: Int64, + bookingID: Int64, + note: String?, + onCompletion: @escaping (String?) -> Void + ) { + storageManager.performAndSave({ storage -> String? in + guard let booking = storage.loadBooking( + siteID: siteID, + bookingID: bookingID + ) else { + return note + } + + let oldNote = booking.note + booking.note = note + return oldNote + }, completion: { result in + switch result { + case .success(let status): + onCompletion(status) + case .failure: + onCompletion(note) + } + }, on: .main) + } + /// Cancels a booking by updating its status to cancelled. func cancelBooking( siteID: Int64, @@ -373,7 +456,8 @@ private extension BookingStore { from: siteID, bookingID: bookingID, attendanceStatus: nil, - bookingStatus: .cancelled + bookingStatus: .cancelled, + note: nil, ) { await upsertStoredBookingsInBackground( readOnlyBookings: [remoteBooking], @@ -403,7 +487,8 @@ private extension BookingStore { from: siteID, bookingID: bookingID, attendanceStatus: nil, - bookingStatus: .paid + bookingStatus: .paid, + note: nil, ) { await upsertStoredBookingsInBackground( readOnlyBookings: [remoteBooking], diff --git a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift index ed57e0f57b1..3b7c4b6fc75 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift @@ -248,6 +248,20 @@ extension BookingDetailsViewModel { stores.dispatch(action) } + func updateNote(to newNote: String) { + let action = BookingAction.updateBookingNote( + siteID: booking.siteID, + bookingID: booking.bookingID, + note: newNote + ) { [weak self] error in + if let error, let self { + DDLogError("⛔️ Error updating booking note: \(error)") +// displayAttendanceStatusUpdatedErrorNotice(status: newStatus) + } + } + stores.dispatch(action) + } + private func displayAttendanceStatusUpdatedErrorNotice(status: BookingAttendanceStatus) { let text = String.localizedStringWithFormat( Localization.bookingAttendanceStatusUpdateFailedMessage, diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift index f43706159b9..84de031ce69 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 + 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..fec0b792843 100644 --- a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextDetailView.swift +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextDetailView.swift @@ -9,11 +9,13 @@ struct MultilineEditableTextDetailView: View { @FocusState private var isFocused: Bool let title: String? + let onCommit: ((String) -> Void)? - init(text: Binding, title: String? = nil) { + init(text: Binding, title: String? = nil, onCommit: ((String) -> Void)? = nil) { self._text = text self._editedText = State(initialValue: text.wrappedValue) self.title = title + self.onCommit = onCommit } var body: some View { @@ -54,6 +56,7 @@ struct MultilineEditableTextDetailView: View { ToolbarItem(placement: .primaryAction) { Button(Localization.doneButtonTitle) { text = editedText + onCommit?(editedText) dismiss() } .fontWeight(.medium) diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextRow.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextRow.swift index a42fe8f7f62..e5754ed9db7 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) -> Void)? - init(value: String, placeholder: String, detailTitle: String? = nil) { - self.value = value + init(value: String, + placeholder: String, + detailTitle: String? = nil, + onCommit: ((String) -> Void)? = nil + ) { + 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 } From 174d39b5f18b543dcd4d51af9d3c3e256a59237b Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Mon, 17 Nov 2025 11:36:31 +0100 Subject: [PATCH 2/6] Adding test --- .../Remote/BookingsRemoteTests.swift | 36 ++++++++++++++++--- .../Mocks/MockBookingsRemote.swift | 4 ++- 2 files changed, 35 insertions(+), 5 deletions(-) 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() } From a8bfd7857139b3c4549b8812539dade68e1fe558 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Mon, 17 Nov 2025 13:06:19 +0100 Subject: [PATCH 3/6] Fix spacing --- Modules/Sources/Networking/Remote/BookingsRemote.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/Networking/Remote/BookingsRemote.swift b/Modules/Sources/Networking/Remote/BookingsRemote.swift index 81c50d670a1..e04c608b252 100644 --- a/Modules/Sources/Networking/Remote/BookingsRemote.swift +++ b/Modules/Sources/Networking/Remote/BookingsRemote.swift @@ -265,6 +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" + static let note: String = "note" } } From faccab8425b1a026cfb09269874b8022cd853545 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Mon, 17 Nov 2025 13:07:19 +0100 Subject: [PATCH 4/6] Improve notice presentation --- .../Yosemite/Actions/BookingAction.swift | 2 +- .../BookingDetailsViewModel.swift | 37 ++++++++++++++----- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/Modules/Sources/Yosemite/Actions/BookingAction.swift b/Modules/Sources/Yosemite/Actions/BookingAction.swift index 4a1901d5f53..5f710654e74 100644 --- a/Modules/Sources/Yosemite/Actions/BookingAction.swift +++ b/Modules/Sources/Yosemite/Actions/BookingAction.swift @@ -92,7 +92,7 @@ public enum BookingAction: Action { bookingID: Int64, onCompletion: (Error?) -> Void) - /// Updates a booking attendance status. + /// Updates a booking note. /// /// - Parameter siteID: The site ID of the booking. /// - Parameter bookingID: The ID of the booking to be updated. diff --git a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift index 3b7c4b6fc75..1578bcbc142 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift @@ -242,7 +242,11 @@ 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) @@ -256,27 +260,32 @@ extension BookingDetailsViewModel { ) { [weak self] error in if let error, let self { DDLogError("⛔️ Error updating booking note: \(error)") -// displayAttendanceStatusUpdatedErrorNotice(status: newStatus) + displayErrorNotice( + messageFormat: Localization.bookingNoteUpdateFailedMessage + ) { [weak self] in + self?.updateNote(to: newNote) + } } } stores.dispatch(action) } - private func displayAttendanceStatusUpdatedErrorNotice(status: BookingAttendanceStatus) { + 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) + guard let self else { return } + retry() } } } @@ -489,6 +498,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.", From b92bafda5e2ecc37b67b7c0d79f3e414162a8f6b Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Tue, 18 Nov 2025 15:28:54 +0100 Subject: [PATCH 5/6] Simplify note update by staying on the edit screen --- .../Yosemite/Stores/BookingStore.swift | 82 +++++-------------- .../BookingDetailsViewModel.swift | 37 +++++---- .../Booking Details/BookingDetailsView.swift | 2 +- .../MultilineEditableTextDetailView.swift | 48 +++++++++-- .../MultilineEditableTextRow.swift | 8 +- 5 files changed, 88 insertions(+), 89 deletions(-) diff --git a/Modules/Sources/Yosemite/Stores/BookingStore.swift b/Modules/Sources/Yosemite/Stores/BookingStore.swift index af7a81c1230..4753075c4b2 100644 --- a/Modules/Sources/Yosemite/Stores/BookingStore.swift +++ b/Modules/Sources/Yosemite/Stores/BookingStore.swift @@ -347,44 +347,27 @@ private extension BookingStore { note: String, onCompletion: @escaping (Error?) -> Void ) { - updateBookingNoteLocally( - siteID: siteID, - bookingID: bookingID, - note: note - ) { [weak self] previousNote in - guard let self else { - return onCompletion(UpdateBookingStatusError.undefinedState) - } - - 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 - ) + 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 { - /// Revert Optimistic Update - self.updateBookingNoteLocally( - siteID: siteID, - bookingID: bookingID, - note: note - ) { _ in - onCompletion(error) - } + onCompletion(nil) + } else { + return onCompletion(UpdateBookingStatusError.missingRemoteBooking) } + } catch { + return onCompletion(error) } } } @@ -417,33 +400,6 @@ private extension BookingStore { }, on: .main) } - func updateBookingNoteLocally( - siteID: Int64, - bookingID: Int64, - note: String?, - onCompletion: @escaping (String?) -> Void - ) { - storageManager.performAndSave({ storage -> String? in - guard let booking = storage.loadBooking( - siteID: siteID, - bookingID: bookingID - ) else { - return note - } - - let oldNote = booking.note - booking.note = note - return oldNote - }, completion: { result in - switch result { - case .success(let status): - onCompletion(status) - case .failure: - onCompletion(note) - } - }, on: .main) - } - /// Cancels a booking by updating its status to cancelled. func cancelBooking( siteID: Int64, diff --git a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift index 1578bcbc142..248389bd3fb 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift @@ -252,22 +252,30 @@ extension BookingDetailsViewModel { stores.dispatch(action) } - func updateNote(to newNote: String) { - let action = BookingAction.updateBookingNote( - siteID: booking.siteID, - bookingID: booking.bookingID, - note: newNote - ) { [weak self] error in - if let error, let self { - DDLogError("⛔️ Error updating booking note: \(error)") - displayErrorNotice( - messageFormat: Localization.bookingNoteUpdateFailedMessage - ) { [weak self] in - self?.updateNote(to: newNote) + @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) } - stores.dispatch(action) } private func displayErrorNotice( @@ -283,8 +291,7 @@ extension BookingDetailsViewModel { message: text, feedbackType: .error, actionTitle: Localization.retryActionTitle - ) { [weak self] in - guard let self else { return } + ) { retry() } } diff --git a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift index 84de031ce69..dfb3d063fee 100644 --- a/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift +++ b/WooCommerce/Classes/ViewRelated/Bookings/Booking Details/BookingDetailsView.swift @@ -245,7 +245,7 @@ private extension BookingDetailsView { MultilineEditableTextRow(value: viewModel.note, placeholder: Localization.bookingNotesRowText, detailTitle: Localization.bookingNoteNavbarText) { newNote in - viewModel.updateNote(to: newNote) + 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 fec0b792843..e955e704c5c 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,11 +12,14 @@ 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 + @State private var errorMessage: String? let title: String? - let onCommit: ((String) -> Void)? + let onCommit: (String) async -> MultilineCommitResult - init(text: Binding, title: String? = nil, onCommit: ((String) -> Void)? = nil) { + init(text: Binding, title: String? = nil, onCommit: @escaping (String) async -> MultilineCommitResult) { self._text = text self._editedText = State(initialValue: text.wrappedValue) self.title = title @@ -31,6 +39,7 @@ struct MultilineEditableTextDetailView: View { .toolbar { toolbar } .wooNavigationBarStyle() .onAppear { isFocused = true } + .notice($notice) } private var toolbar: some ToolbarContent { @@ -54,17 +63,42 @@ struct MultilineEditableTextDetailView: View { if editedText != text { ToolbarItem(placement: .primaryAction) { - Button(Localization.doneButtonTitle) { - text = editedText - onCommit?(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 e5754ed9db7..febca79f970 100644 --- a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextRow.swift +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextRow.swift @@ -4,12 +4,12 @@ struct MultilineEditableTextRow: View { @State var value: String let placeholder: String let detailTitle: String? - let onCommit: ((String) -> Void)? + let onCommit: (String) async -> MultilineCommitResult init(value: String, placeholder: String, detailTitle: String? = nil, - onCommit: ((String) -> Void)? = nil + onCommit: @escaping (String) async -> MultilineCommitResult ) { self._value = State(initialValue: value) self.placeholder = placeholder @@ -64,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) From eb7d0f7e771e1bc5be9c0ceb7e39c9f50238f636 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Wed, 19 Nov 2025 06:59:02 +0100 Subject: [PATCH 6/6] Removed unused code --- .../MultilineEditableTextDetailView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextDetailView.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextDetailView.swift index e955e704c5c..b7dd0a61f3d 100644 --- a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextDetailView.swift +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/MultilineEditableTextRow/MultilineEditableTextDetailView.swift @@ -14,7 +14,6 @@ struct MultilineEditableTextDetailView: View { @FocusState private var isFocused: Bool @State private var notice: Notice? @State private var isSaving = false - @State private var errorMessage: String? let title: String? let onCommit: (String) async -> MultilineCommitResult