From d3b184441a50b7f7244dba438c1c4d69408dec15 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Thu, 16 Oct 2025 15:07:38 +0700 Subject: [PATCH 1/7] Add BookingResource in Networking layer --- .../Sources/Fakes/Networking.generated.swift | 17 ++++ .../Mapper/BookingResourceMapper.swift | 27 ++++++ .../Model/Bookings/BookingResource.swift | 86 +++++++++++++++++++ .../Copiable/Models+Copiable.generated.swift | 36 ++++++++ .../Networking/Remote/BookingsRemote.swift | 22 +++++ .../Remote/BookingsRemoteTests.swift | 33 +++++++ .../Responses/booking-resource.json | 12 +++ 7 files changed, 233 insertions(+) create mode 100644 Modules/Sources/Networking/Mapper/BookingResourceMapper.swift create mode 100644 Modules/Sources/Networking/Model/Bookings/BookingResource.swift create mode 100644 Modules/Tests/NetworkingTests/Responses/booking-resource.json diff --git a/Modules/Sources/Fakes/Networking.generated.swift b/Modules/Sources/Fakes/Networking.generated.swift index 110932bf7b8..c636d5c4e8a 100644 --- a/Modules/Sources/Fakes/Networking.generated.swift +++ b/Modules/Sources/Fakes/Networking.generated.swift @@ -344,6 +344,23 @@ extension Networking.Booking { ) } } +extension Networking.BookingResource { + /// Returns a "ready to use" type filled with fake values. + /// + public static func fake() -> Networking.BookingResource { + .init( + id: .fake(), + name: .fake(), + qty: .fake(), + role: .fake(), + email: .fake(), + phoneNumber: .fake(), + imageID: .fake(), + imageURL: .fake(), + description: .fake() + ) + } +} extension Networking.CompositeComponentOptionType { /// Returns a "ready to use" type filled with fake values. /// diff --git a/Modules/Sources/Networking/Mapper/BookingResourceMapper.swift b/Modules/Sources/Networking/Mapper/BookingResourceMapper.swift new file mode 100644 index 00000000000..138827d02fe --- /dev/null +++ b/Modules/Sources/Networking/Mapper/BookingResourceMapper.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Mapper: BookingResource +/// +struct BookingResourceMapper: Mapper { + let siteID: Int64 + + func map(response: Data) throws -> BookingResource { + let decoder = JSONDecoder() + decoder.userInfo = [ + .siteID: siteID + ] + if hasDataEnvelope(in: response) { + return try decoder.decode(BookingResourceEnvelope.self, from: response).bookingResource + } else { + return try decoder.decode(BookingResource.self, from: response) + } + } +} + +private struct BookingResourceEnvelope: Decodable { + let bookingResource: BookingResource + + private enum CodingKeys: String, CodingKey { + case bookingResource = "data" + } +} diff --git a/Modules/Sources/Networking/Model/Bookings/BookingResource.swift b/Modules/Sources/Networking/Model/Bookings/BookingResource.swift new file mode 100644 index 00000000000..7fda418dea9 --- /dev/null +++ b/Modules/Sources/Networking/Model/Bookings/BookingResource.swift @@ -0,0 +1,86 @@ +import Codegen +import Foundation + +public struct BookingResource: Hashable, Decodable, GeneratedFakeable, GeneratedCopiable { + public let siteID: Int64 + public let id: Int64 + public let name: String + public let qty: Int64 + public let role: String + public let email: String? + public let phoneNumber: String? + public let imageID: Int64 + public let imageURL: String? + public let description: String? + + public init(siteID: Int64, + id: Int64, + name: String, + qty: Int64, + role: String, + email: String?, + phoneNumber: String?, + imageID: Int64, + imageURL: String?, + description: String?) { + self.siteID = siteID + self.id = id + self.name = name + self.qty = qty + self.role = role + self.email = email + self.phoneNumber = phoneNumber + self.imageID = imageID + self.imageURL = imageURL + self.description = description + } + + public init(from decoder: Decoder) throws { + guard let siteID = decoder.userInfo[.siteID] as? Int64 else { + throw BookingResourceDecodingError.missingSiteID + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + + let id = try container.decode(Int64.self, forKey: .id) + let name = try container.decode(String.self, forKey: .name) + let qty = try container.decode(Int64.self, forKey: .qty) + let role = try container.decode(String.self, forKey: .role) + let email = try container.decodeIfPresent(String.self, forKey: .email) + let phoneNumber = try container.decodeIfPresent(String.self, forKey: .phoneNumber) + let imageID = try container.decode(Int64.self, forKey: .imageID) + let imageURL = try container.decodeIfPresent(String.self, forKey: .imageURL) + let description = try container.decodeIfPresent(String.self, forKey: .description) + + self.init(siteID: siteID, + id: id, + name: name, + qty: qty, + role: role, + email: email, + phoneNumber: phoneNumber, + imageID: imageID, + imageURL: imageURL, + description: description) + } +} + +private extension BookingResource { + enum CodingKeys: String, CodingKey { + case id + case name + case qty + case role + case email + case phoneNumber = "phone_number" + case imageID = "image_id" + case imageURL = "image_url" + case description + } +} + +// MARK: - Decoding Errors +// +enum BookingResourceDecodingError: Error { + case missingSiteID +} diff --git a/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift b/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift index e2fae5200ec..101ea8c53b3 100644 --- a/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift +++ b/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift @@ -494,6 +494,42 @@ extension Networking.Booking { } } +extension Networking.BookingResource { + public func copy( + id: CopiableProp = .copy, + name: CopiableProp = .copy, + qty: CopiableProp = .copy, + role: CopiableProp = .copy, + email: NullableCopiableProp = .copy, + phoneNumber: NullableCopiableProp = .copy, + imageID: CopiableProp = .copy, + imageURL: NullableCopiableProp = .copy, + description: NullableCopiableProp = .copy + ) -> Networking.BookingResource { + let id = id ?? self.id + let name = name ?? self.name + let qty = qty ?? self.qty + let role = role ?? self.role + let email = email ?? self.email + let phoneNumber = phoneNumber ?? self.phoneNumber + let imageID = imageID ?? self.imageID + let imageURL = imageURL ?? self.imageURL + let description = description ?? self.description + + return Networking.BookingResource( + id: id, + name: name, + qty: qty, + role: role, + email: email, + phoneNumber: phoneNumber, + imageID: imageID, + imageURL: imageURL, + description: description + ) + } +} + extension Networking.Coupon { public func copy( siteID: CopiableProp = .copy, diff --git a/Modules/Sources/Networking/Remote/BookingsRemote.swift b/Modules/Sources/Networking/Remote/BookingsRemote.swift index eb0b4425f7d..57d6766b6a9 100644 --- a/Modules/Sources/Networking/Remote/BookingsRemote.swift +++ b/Modules/Sources/Networking/Remote/BookingsRemote.swift @@ -16,6 +16,9 @@ public protocol BookingsRemoteProtocol { func loadBooking(bookingID: Int64, siteID: Int64) async throws -> Booking? + + func fetchResource(resourceID: Int64, + siteID: Int64) async throws -> BookingResource? } /// Booking: Remote Endpoints @@ -84,6 +87,24 @@ public final class BookingsRemote: Remote, BookingsRemoteProtocol { return try await enqueue(request, mapper: mapper) } + + public func fetchResource( + resourceID: Int64, + siteID: Int64 + ) async throws -> BookingResource? { + let path = "\(Path.resources)/\(resourceID)" + let request = JetpackRequest( + wooApiVersion: .wcBookings, + method: .get, + siteID: siteID, + path: path, + availableAsRESTRequest: true + ) + + let mapper = BookingResourceMapper(siteID: siteID) + + return try await enqueue(request, mapper: mapper) + } } // MARK: - Constants @@ -101,6 +122,7 @@ public extension BookingsRemote { private enum Path { static let bookings = "bookings" + static let resources = "resources" } private enum ParameterKey { diff --git a/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift index 2be131ebf90..87adc597ec2 100644 --- a/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift @@ -90,4 +90,37 @@ struct BookingsRemoteTests { #expect(parameters["per_page"] != nil) #expect(parameters["order"] != nil) } + + @Test func test_fetchResource_properly_returns_parsed_resource() async throws { + // Given + let remote = BookingsRemote(network: network) + let resourceID: Int64 = 22 + network.simulateResponse(requestUrlSuffix: "resources/\(resourceID)", filename: "booking-resource") + + // When + let resource = try await remote.fetchResource(resourceID: resourceID, siteID: sampleSiteID) + + // Then + let unwrappedResource = try #require(resource) + #expect(unwrappedResource.id == 22) + #expect(unwrappedResource.name == "Joel (Sample resource)") + #expect(unwrappedResource.qty == 1) + #expect(unwrappedResource.role == "") + #expect(unwrappedResource.email == "") + #expect(unwrappedResource.phoneNumber == "") + #expect(unwrappedResource.imageID == 0) + #expect(unwrappedResource.imageURL == "") + #expect(unwrappedResource.description == "") + #expect(unwrappedResource.siteID == sampleSiteID) + } + + @Test func test_fetchResource_properly_relays_networking_errors() async { + // Given + let remote = BookingsRemote(network: network) + + // Then + await #expect(throws: NetworkError.notFound()) { + _ = try await remote.fetchResource(resourceID: 22, siteID: sampleSiteID) + } + } } diff --git a/Modules/Tests/NetworkingTests/Responses/booking-resource.json b/Modules/Tests/NetworkingTests/Responses/booking-resource.json new file mode 100644 index 00000000000..eed3c6081b8 --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/booking-resource.json @@ -0,0 +1,12 @@ +{ + "id": 22, + "name": "Joel (Sample resource)", + "qty": 1, + "role": "", + "email": "", + "phone_number": "", + "image_id": 0, + "image_url": "", + "description": "", + "note": "" +} From d97472a955a438f68e5b7e3139d7b9e3c6759d60 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Thu, 16 Oct 2025 15:33:35 +0700 Subject: [PATCH 2/7] Add BookingResource entity --- .../Sources/Fakes/Networking.generated.swift | 5 ++-- .../Model/Bookings/BookingResource.swift | 24 +++++++++---------- .../Copiable/Models+Copiable.generated.swift | 15 +++++++----- .../BookingResource+CoreDataClass.swift | 7 ++++++ .../BookingResource+CoreDataProperties.swift | 16 +++++++++++++ Modules/Sources/Storage/Model/MIGRATIONS.md | 2 ++ .../Model 128.xcdatamodel/contents | 12 ++++++++++ 7 files changed, 61 insertions(+), 20 deletions(-) create mode 100644 Modules/Sources/Storage/Model/Booking/BookingResource+CoreDataClass.swift create mode 100644 Modules/Sources/Storage/Model/Booking/BookingResource+CoreDataProperties.swift diff --git a/Modules/Sources/Fakes/Networking.generated.swift b/Modules/Sources/Fakes/Networking.generated.swift index c636d5c4e8a..d29ec113b2f 100644 --- a/Modules/Sources/Fakes/Networking.generated.swift +++ b/Modules/Sources/Fakes/Networking.generated.swift @@ -349,9 +349,10 @@ extension Networking.BookingResource { /// public static func fake() -> Networking.BookingResource { .init( - id: .fake(), + siteID: .fake(), + bookingID: .fake(), name: .fake(), - qty: .fake(), + quantity: .fake(), role: .fake(), email: .fake(), phoneNumber: .fake(), diff --git a/Modules/Sources/Networking/Model/Bookings/BookingResource.swift b/Modules/Sources/Networking/Model/Bookings/BookingResource.swift index 7fda418dea9..a91c07b98da 100644 --- a/Modules/Sources/Networking/Model/Bookings/BookingResource.swift +++ b/Modules/Sources/Networking/Model/Bookings/BookingResource.swift @@ -3,9 +3,9 @@ import Foundation public struct BookingResource: Hashable, Decodable, GeneratedFakeable, GeneratedCopiable { public let siteID: Int64 - public let id: Int64 + public let bookingID: Int64 public let name: String - public let qty: Int64 + public let quantity: Int64 public let role: String public let email: String? public let phoneNumber: String? @@ -14,9 +14,9 @@ public struct BookingResource: Hashable, Decodable, GeneratedFakeable, Generated public let description: String? public init(siteID: Int64, - id: Int64, + bookingID: Int64, name: String, - qty: Int64, + quantity: Int64, role: String, email: String?, phoneNumber: String?, @@ -24,9 +24,9 @@ public struct BookingResource: Hashable, Decodable, GeneratedFakeable, Generated imageURL: String?, description: String?) { self.siteID = siteID - self.id = id + self.bookingID = bookingID self.name = name - self.qty = qty + self.quantity = quantity self.role = role self.email = email self.phoneNumber = phoneNumber @@ -42,9 +42,9 @@ public struct BookingResource: Hashable, Decodable, GeneratedFakeable, Generated let container = try decoder.container(keyedBy: CodingKeys.self) - let id = try container.decode(Int64.self, forKey: .id) + let bookingID = try container.decode(Int64.self, forKey: .bookingID) let name = try container.decode(String.self, forKey: .name) - let qty = try container.decode(Int64.self, forKey: .qty) + let quantity = try container.decode(Int64.self, forKey: .quantity) let role = try container.decode(String.self, forKey: .role) let email = try container.decodeIfPresent(String.self, forKey: .email) let phoneNumber = try container.decodeIfPresent(String.self, forKey: .phoneNumber) @@ -53,9 +53,9 @@ public struct BookingResource: Hashable, Decodable, GeneratedFakeable, Generated let description = try container.decodeIfPresent(String.self, forKey: .description) self.init(siteID: siteID, - id: id, + bookingID: bookingID, name: name, - qty: qty, + quantity: quantity, role: role, email: email, phoneNumber: phoneNumber, @@ -67,9 +67,9 @@ public struct BookingResource: Hashable, Decodable, GeneratedFakeable, Generated private extension BookingResource { enum CodingKeys: String, CodingKey { - case id + case bookingID = "id" case name - case qty + case quantity = "qty" case role case email case phoneNumber = "phone_number" diff --git a/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift b/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift index 101ea8c53b3..c4518f8a6a9 100644 --- a/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift +++ b/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift @@ -496,9 +496,10 @@ extension Networking.Booking { extension Networking.BookingResource { public func copy( - id: CopiableProp = .copy, + siteID: CopiableProp = .copy, + bookingID: CopiableProp = .copy, name: CopiableProp = .copy, - qty: CopiableProp = .copy, + quantity: CopiableProp = .copy, role: CopiableProp = .copy, email: NullableCopiableProp = .copy, phoneNumber: NullableCopiableProp = .copy, @@ -506,9 +507,10 @@ extension Networking.BookingResource { imageURL: NullableCopiableProp = .copy, description: NullableCopiableProp = .copy ) -> Networking.BookingResource { - let id = id ?? self.id + let siteID = siteID ?? self.siteID + let bookingID = bookingID ?? self.bookingID let name = name ?? self.name - let qty = qty ?? self.qty + let quantity = quantity ?? self.quantity let role = role ?? self.role let email = email ?? self.email let phoneNumber = phoneNumber ?? self.phoneNumber @@ -517,9 +519,10 @@ extension Networking.BookingResource { let description = description ?? self.description return Networking.BookingResource( - id: id, + siteID: siteID, + bookingID: bookingID, name: name, - qty: qty, + quantity: quantity, role: role, email: email, phoneNumber: phoneNumber, diff --git a/Modules/Sources/Storage/Model/Booking/BookingResource+CoreDataClass.swift b/Modules/Sources/Storage/Model/Booking/BookingResource+CoreDataClass.swift new file mode 100644 index 00000000000..20870054852 --- /dev/null +++ b/Modules/Sources/Storage/Model/Booking/BookingResource+CoreDataClass.swift @@ -0,0 +1,7 @@ +import Foundation +import CoreData + +@objc(BookingResource) +public class BookingResource: NSManagedObject { + +} diff --git a/Modules/Sources/Storage/Model/Booking/BookingResource+CoreDataProperties.swift b/Modules/Sources/Storage/Model/Booking/BookingResource+CoreDataProperties.swift new file mode 100644 index 00000000000..b9c80a23430 --- /dev/null +++ b/Modules/Sources/Storage/Model/Booking/BookingResource+CoreDataProperties.swift @@ -0,0 +1,16 @@ +import Foundation +import CoreData + +extension BookingResource { + @NSManaged public var siteID: Int64 + @NSManaged public var bookingID: Int64 + @NSManaged public var name: String? + @NSManaged public var quantity: Int64 + @NSManaged public var role: String? + @NSManaged public var email: String? + @NSManaged public var phoneNumber: String? + @NSManaged public var imageID: Int64 + @NSManaged public var imageURL: String? + @NSManaged public var descriptionText: String? + +} diff --git a/Modules/Sources/Storage/Model/MIGRATIONS.md b/Modules/Sources/Storage/Model/MIGRATIONS.md index 4fb06043d27..58b314d4d5c 100644 --- a/Modules/Sources/Storage/Model/MIGRATIONS.md +++ b/Modules/Sources/Storage/Model/MIGRATIONS.md @@ -9,6 +9,8 @@ This file documents changes in the WCiOS Storage data model. Please explain any - Added `BookingProductInfo` entity. - Added `BookingPaymentInfo` entity. - Added `orderInfo` relationship to `Booking` entity. +- @itsmeichigo 2025-10-16 + - Added `BookingResource` entity. ## Model 127 (Release 23.4.0.0) - @itsmeichigo 2025-09-23 diff --git a/Modules/Sources/Storage/Resources/WooCommerce.xcdatamodeld/Model 128.xcdatamodel/contents b/Modules/Sources/Storage/Resources/WooCommerce.xcdatamodeld/Model 128.xcdatamodel/contents index 6e425052f9d..a0269e8eef5 100644 --- a/Modules/Sources/Storage/Resources/WooCommerce.xcdatamodeld/Model 128.xcdatamodel/contents +++ b/Modules/Sources/Storage/Resources/WooCommerce.xcdatamodeld/Model 128.xcdatamodel/contents @@ -117,6 +117,18 @@ + + + + + + + + + + + + From 57558982290910ecb8fe59cf153f46a1c3868ef6 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Thu, 16 Oct 2025 15:36:34 +0700 Subject: [PATCH 3/7] Add migration test --- .../CoreData/MigrationTests.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Modules/Tests/StorageTests/CoreData/MigrationTests.swift b/Modules/Tests/StorageTests/CoreData/MigrationTests.swift index e46453b303d..0bdaee25edd 100644 --- a/Modules/Tests/StorageTests/CoreData/MigrationTests.swift +++ b/Modules/Tests/StorageTests/CoreData/MigrationTests.swift @@ -2256,6 +2256,31 @@ final class MigrationTests: XCTestCase { let updatedOrderInfo = migratedBooking.value(forKey: "orderInfo") as? NSManagedObject XCTAssertEqual(updatedOrderInfo, orderInfo) } + + func test_migrating_127_to_128_adds_new_bookingResource_entity() throws { + // Given + let sourceContainer = try startPersistentContainer("Model 127") + let sourceContext = sourceContainer.viewContext + + try sourceContext.save() + + // Confidence Check. BookingResource should not exist in Model 127 + XCTAssertNil(NSEntityDescription.entity(forEntityName: "BookingResource", in: sourceContext)) + + // When + let targetContainer = try migrate(sourceContainer, to: "Model 128") + let targetContext = targetContainer.viewContext + + // Then + XCTAssertEqual(try targetContext.count(entityName: "BookingResource"), 0) + + let resource = insertBookingResource(to: targetContext) + XCTAssertNoThrow(try targetContext.save()) + + XCTAssertEqual(try targetContext.count(entityName: "BookingResource"), 1) + let insertedResource = try XCTUnwrap(targetContext.first(entityName: "BookingResource")) + XCTAssertEqual(insertedResource, resource) + } } // MARK: - Persistent Store Setup and Migrations @@ -3228,4 +3253,14 @@ private extension MigrationTests { "totalTax": "10.00" ]) } + + @discardableResult + func insertBookingResource(to context: NSManagedObjectContext) -> NSManagedObject { + context.insert(entityName: "BookingResource", properties: [ + "siteID": 1, + "bookingID": 22, + "name": "Joel (Sample resource)", + "quantity": 1 + ]) + } } From b8f95ef3c35ac920ac06e80cadd33083bfc01fe8 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Thu, 16 Oct 2025 16:32:46 +0700 Subject: [PATCH 4/7] Update Yosemite to fetch resource --- .../Sources/Fakes/Networking.generated.swift | 2 +- .../Model/Bookings/BookingResource.swift | 12 ++--- .../Copiable/Models+Copiable.generated.swift | 6 +-- .../BookingResource+CoreDataProperties.swift | 2 +- .../Model 128.xcdatamodel/contents | 2 +- .../Tools/StorageType+Extensions.swift | 6 +++ .../Yosemite/Actions/BookingAction.swift | 8 +++ .../BookingResource+ReadOnlyConvertible.swift | 49 +++++++++++++++++++ Modules/Sources/Yosemite/Model/Model.swift | 1 + .../Yosemite/Stores/BookingStore.swift | 49 +++++++++++++++++++ .../Remote/BookingsRemoteTests.swift | 4 +- .../CoreData/MigrationTests.swift | 2 +- .../Mocks/MockBookingsRemote.swift | 12 +++++ .../Stores/BookingStoreTests.swift | 7 +++ 14 files changed, 147 insertions(+), 15 deletions(-) create mode 100644 Modules/Sources/Yosemite/Model/Booking/BookingResource+ReadOnlyConvertible.swift diff --git a/Modules/Sources/Fakes/Networking.generated.swift b/Modules/Sources/Fakes/Networking.generated.swift index d29ec113b2f..221bf6d81a5 100644 --- a/Modules/Sources/Fakes/Networking.generated.swift +++ b/Modules/Sources/Fakes/Networking.generated.swift @@ -350,7 +350,7 @@ extension Networking.BookingResource { public static func fake() -> Networking.BookingResource { .init( siteID: .fake(), - bookingID: .fake(), + resourceID: .fake(), name: .fake(), quantity: .fake(), role: .fake(), diff --git a/Modules/Sources/Networking/Model/Bookings/BookingResource.swift b/Modules/Sources/Networking/Model/Bookings/BookingResource.swift index a91c07b98da..bd1c5cd4442 100644 --- a/Modules/Sources/Networking/Model/Bookings/BookingResource.swift +++ b/Modules/Sources/Networking/Model/Bookings/BookingResource.swift @@ -3,7 +3,7 @@ import Foundation public struct BookingResource: Hashable, Decodable, GeneratedFakeable, GeneratedCopiable { public let siteID: Int64 - public let bookingID: Int64 + public let resourceID: Int64 public let name: String public let quantity: Int64 public let role: String @@ -14,7 +14,7 @@ public struct BookingResource: Hashable, Decodable, GeneratedFakeable, Generated public let description: String? public init(siteID: Int64, - bookingID: Int64, + resourceID: Int64, name: String, quantity: Int64, role: String, @@ -24,7 +24,7 @@ public struct BookingResource: Hashable, Decodable, GeneratedFakeable, Generated imageURL: String?, description: String?) { self.siteID = siteID - self.bookingID = bookingID + self.resourceID = resourceID self.name = name self.quantity = quantity self.role = role @@ -42,7 +42,7 @@ public struct BookingResource: Hashable, Decodable, GeneratedFakeable, Generated let container = try decoder.container(keyedBy: CodingKeys.self) - let bookingID = try container.decode(Int64.self, forKey: .bookingID) + let resourceID = try container.decode(Int64.self, forKey: .resourceID) let name = try container.decode(String.self, forKey: .name) let quantity = try container.decode(Int64.self, forKey: .quantity) let role = try container.decode(String.self, forKey: .role) @@ -53,7 +53,7 @@ public struct BookingResource: Hashable, Decodable, GeneratedFakeable, Generated let description = try container.decodeIfPresent(String.self, forKey: .description) self.init(siteID: siteID, - bookingID: bookingID, + resourceID: resourceID, name: name, quantity: quantity, role: role, @@ -67,7 +67,7 @@ public struct BookingResource: Hashable, Decodable, GeneratedFakeable, Generated private extension BookingResource { enum CodingKeys: String, CodingKey { - case bookingID = "id" + case resourceID = "id" case name case quantity = "qty" case role diff --git a/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift b/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift index c4518f8a6a9..91f7c131fe2 100644 --- a/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift +++ b/Modules/Sources/Networking/Model/Copiable/Models+Copiable.generated.swift @@ -497,7 +497,7 @@ extension Networking.Booking { extension Networking.BookingResource { public func copy( siteID: CopiableProp = .copy, - bookingID: CopiableProp = .copy, + resourceID: CopiableProp = .copy, name: CopiableProp = .copy, quantity: CopiableProp = .copy, role: CopiableProp = .copy, @@ -508,7 +508,7 @@ extension Networking.BookingResource { description: NullableCopiableProp = .copy ) -> Networking.BookingResource { let siteID = siteID ?? self.siteID - let bookingID = bookingID ?? self.bookingID + let resourceID = resourceID ?? self.resourceID let name = name ?? self.name let quantity = quantity ?? self.quantity let role = role ?? self.role @@ -520,7 +520,7 @@ extension Networking.BookingResource { return Networking.BookingResource( siteID: siteID, - bookingID: bookingID, + resourceID: resourceID, name: name, quantity: quantity, role: role, diff --git a/Modules/Sources/Storage/Model/Booking/BookingResource+CoreDataProperties.swift b/Modules/Sources/Storage/Model/Booking/BookingResource+CoreDataProperties.swift index b9c80a23430..886e5244137 100644 --- a/Modules/Sources/Storage/Model/Booking/BookingResource+CoreDataProperties.swift +++ b/Modules/Sources/Storage/Model/Booking/BookingResource+CoreDataProperties.swift @@ -3,7 +3,7 @@ import CoreData extension BookingResource { @NSManaged public var siteID: Int64 - @NSManaged public var bookingID: Int64 + @NSManaged public var resourceID: Int64 @NSManaged public var name: String? @NSManaged public var quantity: Int64 @NSManaged public var role: String? diff --git a/Modules/Sources/Storage/Resources/WooCommerce.xcdatamodeld/Model 128.xcdatamodel/contents b/Modules/Sources/Storage/Resources/WooCommerce.xcdatamodeld/Model 128.xcdatamodel/contents index a0269e8eef5..4d6ac38ed89 100644 --- a/Modules/Sources/Storage/Resources/WooCommerce.xcdatamodeld/Model 128.xcdatamodel/contents +++ b/Modules/Sources/Storage/Resources/WooCommerce.xcdatamodeld/Model 128.xcdatamodel/contents @@ -118,7 +118,6 @@ - @@ -126,6 +125,7 @@ + diff --git a/Modules/Sources/Storage/Tools/StorageType+Extensions.swift b/Modules/Sources/Storage/Tools/StorageType+Extensions.swift index ea9258e4e8b..568ecdef1d0 100644 --- a/Modules/Sources/Storage/Tools/StorageType+Extensions.swift +++ b/Modules/Sources/Storage/Tools/StorageType+Extensions.swift @@ -966,4 +966,10 @@ public extension StorageType { let objects = allObjects(ofType: Booking.self, matching: predicate, sortedBy: [descriptor]) return objects.isEmpty ? nil : objects } + + /// Retrieves the store booking resource + func loadBookingResource(siteID: Int64, resourceID: Int64) -> BookingResource? { + let predicate = \BookingResource.resourceID == resourceID && \BookingResource.siteID == siteID + return firstObject(ofType: BookingResource.self, matching: predicate) + } } diff --git a/Modules/Sources/Yosemite/Actions/BookingAction.swift b/Modules/Sources/Yosemite/Actions/BookingAction.swift index 4f25e8379d5..5bfe047cafa 100644 --- a/Modules/Sources/Yosemite/Actions/BookingAction.swift +++ b/Modules/Sources/Yosemite/Actions/BookingAction.swift @@ -44,4 +44,12 @@ public enum BookingAction: Action { startDateAfter: String? = nil, order: BookingsRemote.Order = .descending, onCompletion: (Result<[Booking], Error>) -> Void) + + /// Fetches a booking resource by resource ID. + /// + /// - Parameter onCompletion: called when fetch completes, returns an error or the booking resource. + /// + case fetchResource(siteID: Int64, + resourceID: Int64, + onCompletion: (Result) -> Void) } diff --git a/Modules/Sources/Yosemite/Model/Booking/BookingResource+ReadOnlyConvertible.swift b/Modules/Sources/Yosemite/Model/Booking/BookingResource+ReadOnlyConvertible.swift new file mode 100644 index 00000000000..d1673704880 --- /dev/null +++ b/Modules/Sources/Yosemite/Model/Booking/BookingResource+ReadOnlyConvertible.swift @@ -0,0 +1,49 @@ +import Foundation +import Storage + +// MARK: - Storage.BookingResource: ReadOnlyConvertible +// +extension Storage.BookingResource: ReadOnlyConvertible { + + /// Updates the Storage.BookingResource with the a ReadOnly. + /// + public func update(with resource: Yosemite.BookingResource) { + siteID = resource.siteID + resourceID = resource.resourceID + name = resource.name + quantity = resource.quantity + role = resource.role + email = resource.email + phoneNumber = resource.phoneNumber + imageID = resource.imageID + imageURL = resource.imageURL + descriptionText = resource.description + } + + /// Returns a ReadOnly version of the receiver. + /// + public func toReadOnly() -> Yosemite.BookingResource { + BookingResource(siteID: siteID, + resourceID: resourceID, + name: name ?? "", + quantity: quantity, + role: role ?? "", + email: email, + phoneNumber: phoneNumber, + imageID: imageID, + imageURL: imageURL, + description: descriptionText) + } +} + +extension Yosemite.BookingResource: ReadOnlyType { + /// Indicates if the receiver is a representation of a specified Storage.Entity instance. + /// + public func isReadOnlyRepresentation(of storageEntity: Any) -> Bool { + guard let storageResource = storageEntity as? Storage.BookingResource else { + return false + } + + return siteID == Int(storageResource.siteID) && resourceID == Int(storageResource.resourceID) + } +} diff --git a/Modules/Sources/Yosemite/Model/Model.swift b/Modules/Sources/Yosemite/Model/Model.swift index ab9bd6b2e50..0d7a58a8709 100644 --- a/Modules/Sources/Yosemite/Model/Model.swift +++ b/Modules/Sources/Yosemite/Model/Model.swift @@ -30,6 +30,7 @@ public typealias BookingOrderInfo = Networking.BookingOrderInfo public typealias BookingCustomerInfo = Networking.BookingCustomerInfo public typealias BookingPaymentInfo = Networking.BookingPaymentInfo public typealias BookingProductInfo = Networking.BookingProductInfo +public typealias BookingResource = Networking.BookingResource public typealias CreateBlazeCampaign = Networking.CreateBlazeCampaign public typealias FallibleCancelable = Hardware.FallibleCancelable public typealias CommentStatus = Networking.CommentStatus diff --git a/Modules/Sources/Yosemite/Stores/BookingStore.swift b/Modules/Sources/Yosemite/Stores/BookingStore.swift index 5b3cf29bac4..079f4c2ab92 100644 --- a/Modules/Sources/Yosemite/Stores/BookingStore.swift +++ b/Modules/Sources/Yosemite/Stores/BookingStore.swift @@ -61,6 +61,8 @@ public class BookingStore: Store { startDateAfter: startDateAfter, order: order, onCompletion: onCompletion) + case let .fetchResource(siteID, resourceID, onCompletion): + fetchResource(siteID: siteID, resourceID: resourceID, onCompletion: onCompletion) } } } @@ -214,6 +216,36 @@ private extension BookingStore { } } } + + /// Fetches a booking resource by resource ID and saves it to storage. + /// + func fetchResource(siteID: Int64, + resourceID: Int64, + onCompletion: @escaping (Result) -> Void) { + enum FetchResourceError: Error { + case resourceIsMissing + } + + Task { @MainActor in + do { + let resource = try await remote.fetchResource( + resourceID: resourceID, + siteID: siteID + ) + + guard let resource else { + onCompletion(.failure(FetchResourceError.resourceIsMissing)) + return + } + + await upsertBookingResourceInBackground(readOnlyBookingResource: resource) + + onCompletion(.success(resource)) + } catch { + onCompletion(.failure(error)) + } + } + } } @@ -309,4 +341,21 @@ private extension BookingStore { storageBooking.update(with: readOnlyBooking) } } + + /// Updates (OR Inserts) the specified ReadOnly BookingResource Entities *in a background thread* async. + func upsertBookingResourceInBackground(readOnlyBookingResource: 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) + }, completion: { + continuation.resume() + }, on: .main) + } + } } diff --git a/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift index 87adc597ec2..713bea27ff7 100644 --- a/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift @@ -102,9 +102,9 @@ struct BookingsRemoteTests { // Then let unwrappedResource = try #require(resource) - #expect(unwrappedResource.id == 22) + #expect(unwrappedResource.resourceID == 22) #expect(unwrappedResource.name == "Joel (Sample resource)") - #expect(unwrappedResource.qty == 1) + #expect(unwrappedResource.quantity == 1) #expect(unwrappedResource.role == "") #expect(unwrappedResource.email == "") #expect(unwrappedResource.phoneNumber == "") diff --git a/Modules/Tests/StorageTests/CoreData/MigrationTests.swift b/Modules/Tests/StorageTests/CoreData/MigrationTests.swift index 0bdaee25edd..515828fc98f 100644 --- a/Modules/Tests/StorageTests/CoreData/MigrationTests.swift +++ b/Modules/Tests/StorageTests/CoreData/MigrationTests.swift @@ -3258,7 +3258,7 @@ private extension MigrationTests { func insertBookingResource(to context: NSManagedObjectContext) -> NSManagedObject { context.insert(entityName: "BookingResource", properties: [ "siteID": 1, - "bookingID": 22, + "resourceID": 22, "name": "Joel (Sample resource)", "quantity": 1 ]) diff --git a/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift index 0ee3b2e7f22..6cef7c01ca4 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift @@ -6,6 +6,7 @@ import Foundation final class MockBookingsRemote: BookingsRemoteProtocol { private var loadAllBookingsResult: Result<[Booking], Error>? private var loadBookingResult: Result? + private var fetchResourceResult: Result? func whenLoadingAllBookings(thenReturn result: Result<[Booking], Error>) { loadAllBookingsResult = result @@ -15,6 +16,10 @@ final class MockBookingsRemote: BookingsRemoteProtocol { loadBookingResult = result } + func whenFetchingResource(thenReturn result: Result) { + fetchResourceResult = result + } + func loadAllBookings(for siteID: Int64, pageNumber: Int, pageSize: Int, @@ -34,4 +39,11 @@ final class MockBookingsRemote: BookingsRemoteProtocol { } return try result.get() } + + func fetchResource(resourceID: Int64, siteID: Int64) async throws -> Networking.BookingResource? { + guard let result = fetchResourceResult 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 769edd08c22..fb01fa18c5c 100644 --- a/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift +++ b/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift @@ -762,6 +762,13 @@ private extension BookingStoreTests { storedProduct.update(with: product) return storedProduct } + + @discardableResult + func storeBookingResource(_ resource: Networking.BookingResource) -> Storage.BookingResource { + let storedResource = storage.insertNewObject(ofType: Storage.BookingResource.self) + storedResource.update(with: resource) + return storedResource + } } private class MockOrdersRemote: OrdersRemoteProtocol { From e43f9323ad7bd77ed835e86e278891bca94c6cf8 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Thu, 16 Oct 2025 17:37:43 +0700 Subject: [PATCH 5/7] Fetch and display resource on UI --- .../AppointmentDetailsContent.swift | 12 ++++-- .../BookingDetailsViewModel.swift | 38 ++++++++++++++++--- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/WooCommerce/Classes/ViewModels/Booking Details/AppointmentDetailsContent.swift b/WooCommerce/Classes/ViewModels/Booking Details/AppointmentDetailsContent.swift index 6d80c0d7c14..3511930aac7 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/AppointmentDetailsContent.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/AppointmentDetailsContent.swift @@ -1,5 +1,5 @@ import Foundation -import struct Networking.Booking +import Yosemite extension BookingDetailsViewModel { struct AppointmentDetailsContent { @@ -14,17 +14,21 @@ extension BookingDetailsViewModel { let rows: [Row] - init(_ booking: Booking) { + init(_ booking: Booking, resource: BookingResource?) { let appointmentDate = booking.startDate.toString(dateStyle: .short, timeStyle: .none, timeZone: BookingListTab.utcTimeZone) let appointmentTimeFrame = [ booking.startDate.toString(dateStyle: .none, timeStyle: .short, timeZone: BookingListTab.utcTimeZone), booking.endDate.toString(dateStyle: .none, timeStyle: .short, timeZone: BookingListTab.utcTimeZone) ].joined(separator: " - ") + let resourceRow: Row? = { + guard booking.resourceID > 0 else { return nil } + return Row(title: Localization.appointmentDetailsAssignedStaffTitle, value: resource?.name ?? "-") + }() rows = [ Row(title: Localization.appointmentDetailsDateRowTitle, value: appointmentDate), Row(title: Localization.appointmentDetailsTimeRowTitle, value: appointmentTimeFrame), - Row(title: Localization.appointmentDetailsAssignedStaffTitle, value: "Marianne Renoir"), /// Temporarily hardcoded + resourceRow, Row(title: Localization.appointmentDetailsLocationTitle, value: "238 Willow Creek Drive, Montgomery ..."), /// Temporarily hardcoded Row( title: Localization.appointmentDetailsDurationTitle, @@ -37,7 +41,7 @@ extension BookingDetailsViewModel { title: Localization.appointmentDetailsPriceTitle, value: BookingDetailsViewModel.formatPrice(for: booking, priceString: booking.cost) ) - ] + ].compactMap { $0 } } } } diff --git a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift index 90d69b6863a..0859bf02328 100644 --- a/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Booking Details/BookingDetailsViewModel.swift @@ -1,10 +1,12 @@ import Foundation import Yosemite +import protocol Storage.StorageManagerType final class BookingDetailsViewModel: ObservableObject { private let stores: StoresManager private var booking: Booking + private var bookingResource: BookingResource? // EntityListener: Update / Deletion Notifications. /// @@ -15,16 +17,20 @@ final class BookingDetailsViewModel: ObservableObject { let navigationTitle: String @Published private(set) var sections: [Section] = [] - init(booking: Booking, stores: StoresManager = ServiceLocator.stores) { + init(booking: Booking, + stores: StoresManager = ServiceLocator.stores, + storage: StorageManagerType = ServiceLocator.storageManager) { self.booking = booking self.stores = stores navigationTitle = Self.navigationTitle(for: booking) - setupSections(with: booking) + let resource = storage.viewStorage.loadBookingResource(siteID: booking.siteID, resourceID: booking.resourceID)?.toReadOnly() + self.bookingResource = resource + setupSections(with: booking, resource: resource) configureEntityListener() } - private func setupSections(with booking: Booking) { + private func setupSections(with booking: Booking, resource: BookingResource?) { let headerContent = HeaderContent(booking) let headerSection = Section( content: .header(headerContent) @@ -32,7 +38,7 @@ final class BookingDetailsViewModel: ObservableObject { let appointmentDetailsSection = Section( header: .title(Localization.appointmentDetailsSectionHeaderTitle.uppercased()), - content: .appointmentDetails(AppointmentDetailsContent(booking)) + content: .appointmentDetails(AppointmentDetailsContent(booking, resource: resource)) ) let customerSection: Section? = { @@ -75,6 +81,9 @@ final class BookingDetailsViewModel: ObservableObject { extension BookingDetailsViewModel { func syncData() async { + if let resource = await fetchResource() { + self.bookingResource = resource // only update resource if fetching succeeds + } await syncBooking() } } @@ -84,7 +93,7 @@ private extension BookingDetailsViewModel { entityListener.onUpsert = { [weak self] booking in guard let self else { return } self.booking = booking - self.setupSections(with: booking) + self.setupSections(with: booking, resource: bookingResource) } } @@ -96,6 +105,25 @@ private extension BookingDetailsViewModel { } } + @MainActor + func fetchResource() async -> BookingResource? { + do { + return try await withCheckedThrowingContinuation { continuation in + stores.dispatch(BookingAction.fetchResource(siteID: booking.siteID, resourceID: booking.resourceID) { result in + switch result { + case .success(let resource): + continuation.resume(returning: resource) + case .failure(let error): + continuation.resume(throwing: error) + } + }) + } + } catch { + DDLogError("⛔️ Error fetching resource for Booking: \(error)") + return nil + } + } + @MainActor func retrieveBooking() async throws { try await withCheckedThrowingContinuation { continuation in From 3a26509d7ab44ef4246825c5b1b67a61136cbb0c Mon Sep 17 00:00:00 2001 From: Huong Do Date: Fri, 17 Oct 2025 07:48:18 +0700 Subject: [PATCH 6/7] Update new resource endpoint --- 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 57d6766b6a9..6ed9101ec6f 100644 --- a/Modules/Sources/Networking/Remote/BookingsRemote.swift +++ b/Modules/Sources/Networking/Remote/BookingsRemote.swift @@ -122,7 +122,7 @@ public extension BookingsRemote { private enum Path { static let bookings = "bookings" - static let resources = "resources" + static let resources = "resources/team-members" } private enum ParameterKey { From 0c2def23b5c216bb8972bb31da3051f8d1fa0593 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Fri, 17 Oct 2025 08:37:04 +0700 Subject: [PATCH 7/7] Fix failed test --- Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift index 713bea27ff7..78eca50eaef 100644 --- a/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift @@ -95,7 +95,7 @@ struct BookingsRemoteTests { // Given let remote = BookingsRemote(network: network) let resourceID: Int64 = 22 - network.simulateResponse(requestUrlSuffix: "resources/\(resourceID)", filename: "booking-resource") + network.simulateResponse(requestUrlSuffix: "resources/team-members/\(resourceID)", filename: "booking-resource") // When let resource = try await remote.fetchResource(resourceID: resourceID, siteID: sampleSiteID)