diff --git a/Modules/Sources/Networking/Model/ShippingLabel/Shipments/WooShippingShipmentItem.swift b/Modules/Sources/Networking/Model/ShippingLabel/Shipments/WooShippingShipmentItem.swift index 26b3f545505..7406f7e7186 100644 --- a/Modules/Sources/Networking/Model/ShippingLabel/Shipments/WooShippingShipmentItem.swift +++ b/Modules/Sources/Networking/Model/ShippingLabel/Shipments/WooShippingShipmentItem.swift @@ -49,3 +49,10 @@ public struct WooShippingShipmentItem: Codable, Equatable, GeneratedFakeable, Ge } public typealias WooShippingShipments = [String: [WooShippingShipmentItem]] + +public extension WooShippingShipmentItem { + var quantity: Decimal { + guard let subItems else { return 0 } + return subItems.count > 0 ? Decimal(subItems.count) : 1 + } +} diff --git a/Modules/Sources/Yosemite/Stores/WooShippingStore.swift b/Modules/Sources/Yosemite/Stores/WooShippingStore.swift index ed683d48b0d..991d644e2d3 100644 --- a/Modules/Sources/Yosemite/Stores/WooShippingStore.swift +++ b/Modules/Sources/Yosemite/Stores/WooShippingStore.swift @@ -326,7 +326,12 @@ private extension WooShippingStore { // If label has PURCHASED status, stop polling if labelStatusResponse.status == .purchased, let label = labelStatusResponse.getPurchasedLabel() { - completion(.success(label)) + guard let self else { + return completion(.success(label)) + } + insertPurchasedLabelInBackground(siteID: siteID, orderID: orderID, shippingLabel: label) { + completion(.success(label)) + } } // If label has PURCHASE_ERROR status, return error and stop polling @@ -410,7 +415,23 @@ private extension WooShippingStore { orderID: Int64, shipmentToUpdate: WooShippingUpdateShipment, completion: @escaping (Result) -> Void) { - remote.updateShipment(siteID: siteID, orderID: orderID, shipmentToUpdate: shipmentToUpdate, completion: completion) + remote.updateShipment(siteID: siteID, orderID: orderID, shipmentToUpdate: shipmentToUpdate) { [weak self] result in + guard let self, let contents = try? result.get() else { + return completion(result) + } + let shipments = contents.map { (index, items) in + WooShippingShipment(siteID: siteID, + orderID: orderID, + index: index, + items: items, + shippingLabel: nil) + } + upsertShipmentsInBackground(siteID: siteID, + orderID: orderID, + shipments: shipments) { + completion(.success(contents)) + } + } } } @@ -644,10 +665,15 @@ private extension WooShippingStore { DDLogWarn("⚠️ No shipping label found in storage when updating refund") return shippingLabel.copy(refund: refund) } + let storageShipment = storageShippingLabel.shipment let storageRefund = storageShippingLabel.refund ?? storage.insertNewObject(ofType: Storage.ShippingLabelRefund.self) storageRefund.update(with: refund) storageShippingLabel.refund = storageRefund + + // update stored shipment to trigger onDidChangeContent notification + storageShipment?.shippingLabel = storageShippingLabel + return storageShippingLabel.toReadOnly() }, completion: { result in @@ -676,6 +702,28 @@ private extension WooShippingStore { }, completion: nil, on: .main) } + /// Inserts the specified readonly shipping label entity *in a background thread*. + /// `onCompletion` will be called on the main thread! + func insertPurchasedLabelInBackground(siteID: Int64, + orderID: Int64, + shippingLabel: ShippingLabel, + onCompletion: @escaping () -> Void) { + storageManager.performAndSave({ [weak self] storage in + guard let self else { return } + + let storageOrder = storage.loadOrder(siteID: siteID, orderID: orderID) + let storageShipment = storage.loadAllShipments(siteID: siteID, orderID: orderID) + .first(where: { $0.index == shippingLabel.shipmentID }) + + guard let storageOrder, let storageShipment else { return } + + update(storageShipment: storageShipment, + storageOrder: storageOrder, + shippingLabel: shippingLabel, + using: storage) + }, completion: onCompletion, on: .main) + } + /// Updates/inserts the specified readonly shipments entities *in a background thread*. /// `onCompletion` will be called on the main thread! func upsertShipmentsInBackground(siteID: Int64, diff --git a/Modules/Tests/YosemiteTests/Stores/WooShippingStoreTests.swift b/Modules/Tests/YosemiteTests/Stores/WooShippingStoreTests.swift index 4e19f573f67..ee1f3fa8e56 100644 --- a/Modules/Tests/YosemiteTests/Stores/WooShippingStoreTests.swift +++ b/Modules/Tests/YosemiteTests/Stores/WooShippingStoreTests.swift @@ -531,17 +531,22 @@ final class WooShippingStoreTests: XCTestCase { // MARK: `purchaseShippingLabel` - func test_purchaseShippingLabel_returns_shipping_label_on_success() throws { + func test_purchaseShippingLabel_returns_shipping_label_on_success_and_persists_label_in_storage() throws { // Given - let expectedLabel = ShippingLabel.fake().copy(shippingLabelID: 13579) + let expectedLabel = ShippingLabel.fake().copy(siteID: sampleSiteID, orderID: sampleOrderID, shippingLabelID: 13579, shipmentID: "0") let labelStatusResponse = ShippingLabelStatusPollingResponse.purchased(expectedLabel) let remote = MockWooShippingRemote() remote.whenPurchaseShippingLabel(siteID: sampleSiteID, thenReturn: .success([ShippingLabelPurchase.fake().copy(shippingLabelID: 13579)])) remote.whenCheckLabelStatus(siteID: sampleSiteID, thenReturn: .success(labelStatusResponse)) + + let order = insertOrder(siteID: sampleSiteID, orderID: sampleOrderID) + let shipment = insertShipment(siteID: sampleSiteID, orderID: sampleOrderID, index: "0") + shipment.order = order + let store = WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote) // When - let result: Result = waitFor { promise in + let result: Result = waitFor(timeout: 10) { promise in let action = WooShippingAction.purchaseShippingLabel(siteID: self.sampleSiteID, orderID: self.sampleOrderID, originAddress: .fake(), @@ -558,6 +563,15 @@ final class WooShippingStoreTests: XCTestCase { XCTAssertTrue(result.isSuccess) let actualLabel = try XCTUnwrap(result.get()) XCTAssertEqual(actualLabel, expectedLabel) + + // label is persisted + let storedLabels = storageManager.viewStorage.loadAllShippingLabels(siteID: sampleSiteID, orderID: sampleOrderID) + XCTAssertEqual(storedLabels.count, 1) + XCTAssertEqual(storedLabels.first?.shippingLabelID, expectedLabel.shippingLabelID) + + let storedShipments = storageManager.viewStorage.loadAllShipments(siteID: sampleSiteID, orderID: sampleOrderID) + XCTAssertEqual(storedShipments.count, 1) + XCTAssertEqual(storedShipments.first?.shippingLabel?.shippingLabelID, expectedLabel.shippingLabelID) } func test_purchaseShippingLabel_returns_error_on_purchaseShippingLabel_request_failure() throws { @@ -1037,11 +1051,13 @@ final class WooShippingStoreTests: XCTestCase { // MARK: `updateShipment` - func test_updateShipment_returns_success_response() throws { + func test_updateShipment_returns_success_response_and_persists_shipments() throws { // Given let remote = MockWooShippingRemote() let expected = ["0": [WooShippingShipmentItem.fake()]] remote.whenUpdatingShipment(siteID: sampleSiteID, thenReturn: .success(expected)) + + insertOrder(siteID: sampleSiteID, orderID: sampleOrderID) let store = WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote) // When @@ -1057,6 +1073,10 @@ final class WooShippingStoreTests: XCTestCase { // Then let actual = try XCTUnwrap(result.get()) XCTAssertEqual(actual, expected) + + let storedShipments = storageManager.viewStorage.loadAllShipments(siteID: sampleSiteID, orderID: sampleOrderID) + XCTAssertEqual(storedShipments.count, 1) + XCTAssertEqual(storedShipments.first?.index, "0") } func test_updateShipment_returns_error_on_failure() throws { @@ -1088,7 +1108,7 @@ final class WooShippingStoreTests: XCTestCase { let sampleOrderID: Int64 = 134 let remote = MockWooShippingRemote() let expectedRefund = Yosemite.ShippingLabelRefund(dateRequested: Date(), status: .pending) - let shippingLabel = MockShippingLabel.emptyLabel().copy(siteID: sampleSiteID, orderID: sampleOrderID, shippingLabelID: 123) + let shippingLabel = MockShippingLabel.emptyLabel().copy(siteID: sampleSiteID, orderID: sampleOrderID, shippingLabelID: 123, shipmentID: "0") remote.whenRefundingShippingLabel(siteID: shippingLabel.siteID, orderID: shippingLabel.orderID, @@ -1096,8 +1116,10 @@ final class WooShippingStoreTests: XCTestCase { thenReturn: .success(expectedRefund)) let store = WooShippingStore(dispatcher: dispatcher, storageManager: storageManager, network: network, remote: remote) + let shipment = insertShipment(siteID: sampleSiteID, orderID: sampleOrderID, index: "0") // Inserts a shipping label without a refund. - insertShippingLabel(shippingLabel) + let storedLabel = insertShippingLabel(shippingLabel) + shipment.shippingLabel = storedLabel XCTAssertEqual(viewStorage.countObjects(ofType: StorageShippingLabel.self), 1) XCTAssertEqual(viewStorage.countObjects(ofType: StorageShippingLabelRefund.self), 0) @@ -1121,6 +1143,10 @@ final class WooShippingStoreTests: XCTestCase { XCTAssertEqual(viewStorage.countObjects(ofType: StorageShippingLabel.self), 1) XCTAssertEqual(viewStorage.countObjects(ofType: StorageShippingLabelRefund.self), 1) + + let storedShipments = viewStorage.loadAllShipments(siteID: sampleSiteID, orderID: sampleOrderID) + XCTAssertEqual(storedShipments.first?.shippingLabel?.shippingLabelID, shippingLabel.shippingLabelID) + XCTAssertNotNil(storedShipments.first?.shippingLabel?.refund) } func test_refundShippingLabel_returns_error_on_failure() throws { @@ -1390,15 +1416,28 @@ private extension WooShippingStoreTests { groupId: "")])]) } - func insertShippingLabel(_ readOnlyShippingLabel: Yosemite.ShippingLabel) { + @discardableResult + func insertShippingLabel(_ readOnlyShippingLabel: Yosemite.ShippingLabel) -> StorageShippingLabel { let shippingLabel = viewStorage.insertNewObject(ofType: StorageShippingLabel.self) shippingLabel.update(with: readOnlyShippingLabel) + return shippingLabel } - func insertOrder(siteID: Int64, orderID: Int64) { + @discardableResult + func insertOrder(siteID: Int64, orderID: Int64) -> StorageOrder { let order = viewStorage.insertNewObject(ofType: StorageOrder.self) order.siteID = siteID order.orderID = orderID order.statusKey = "" + return order + } + + @discardableResult + func insertShipment(siteID: Int64, orderID: Int64, index: String) -> StorageWooShippingShipment { + let shipment = viewStorage.insertNewObject(ofType: StorageWooShippingShipment.self) + shipment.siteID = siteID + shipment.orderID = orderID + shipment.index = index + return shipment } } diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 164b4a32c36..63782b3a502 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -3,6 +3,7 @@ 22.9 ----- +- [**] Order Details: Update Shipping Labels section for stores with Woo Shipping extension [https://github.com/woocommerce/woocommerce-ios/pull/15889] - [*] Order List: New orders made through Point of Sale are now filterable via the Order List filters menu [https://github.com/woocommerce/woocommerce-ios/pull/15910] 22.8 diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsDataSource.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsDataSource.swift index 63d0e786621..43c113f9ce0 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsDataSource.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsDataSource.swift @@ -65,8 +65,7 @@ final class OrderDetailsDataSource: NSObject { /// var shouldShowShippingLabelCreation: Bool { if featureFlags.isFeatureFlagEnabled(.revampedShippingLabelCreation) { - // TODO-15375: update logic to show shipping label creation button - return isEligibleForShippingLabelCreation && !isEligibleForPayment + return isEligibleForShippingLabelCreation && !isEligibleForPayment && shipments.isEmpty } return isEligibleForShippingLabelCreation && shippingLabels.nonRefunded.isEmpty && !isEligibleForPayment } @@ -76,7 +75,8 @@ final class OrderDetailsDataSource: NSObject { var shouldAllowRecreatingShippingLabels: Bool { isEligibleForShippingLabelCreation && shippingLabels.isNotEmpty && - !isEligibleForPayment + !isEligibleForPayment && + !isEligibleForWooShipping } /// Whether the option to install the WCShip extension should be visible. @@ -179,6 +179,9 @@ final class OrderDetailsDataSource: NSObject { /// private(set) var shippingLabels: [ShippingLabel] = [] + /// Shipments in an order + private(set) var shipments: [WooShippingShipment] = [] + private var shippingLabelOrderItemsAggregator: AggregatedShippingLabelOrderItems = AggregatedShippingLabelOrderItems.empty /// Shipping Lines from an Order @@ -535,6 +538,8 @@ private extension OrderDetailsDataSource { configureTrashOrder(cell: cell, at: indexPath) case let cell as HostingConfigurationTableViewCell where row == .shippingLine: configureShippingLine(cell: cell, at: indexPath) + case let cell as HostingConfigurationTableViewCell where row == .shipmentDetails: + configureShipmentDetail(cell: cell, at: indexPath) default: fatalError("Unidentified customer info row type") } @@ -720,7 +725,7 @@ private extension OrderDetailsDataSource { cell.configure(style: .primary, title: Titles.createShippingLabel, bottomSpacing: 0) { - self.onCellAction?(.createShippingLabel, nil) + self.onCellAction?(.createShippingLabel(shipmentIndex: nil), nil) } cell.hideSeparator() } @@ -1085,6 +1090,54 @@ private extension OrderDetailsDataSource { } + func configureShipmentDetail(cell: HostingConfigurationTableViewCell, + at indexPath: IndexPath) { + guard let shipment = shipments[safe: indexPath.row] else { + ServiceLocator.crashLogging.logMessage( + "Invalid shipment index in OrderDetailsDataSource", + properties: [ + "row": indexPath.row, + "section": indexPath.section, + "availableShippingLinesCount": shipments.count + ], + level: .error + ) + return + } + + let totalShipmentCount = shipments.count + let view = OrderDetailsShipmentDetailsView( + shipment: shipment, + totalShipmentCount: totalShipmentCount, + eligibleForCreatingShippingLabel: isEligibleForShippingLabelCreation, + onViewItems: { [weak self] in + self?.onCellAction?(.viewShipmentItems(shipment: shipment), indexPath) + }, + onCreateLabel: { [weak self] in + self?.onCellAction?(.createShippingLabel(shipmentIndex: indexPath.row), indexPath) + }, + onViewLabel: { [weak self] label in + self?.onCellAction?(.openShippingLabelForm(shippingLabel: label), indexPath) + }, + onPrintLabel: { [weak self] label in + self?.onCellAction?(.reprintShippingLabel(shippingLabel: label), indexPath) + }, + onPrintCustomsForm: { [weak self] url in + self?.onCellAction?(.printCustomsForm(url: url), indexPath) + }, + onRefund: { [weak self] label in + self?.onCellAction?(.refundShippingLabel(shippingLabel: label), indexPath) + } + ) + + cell.host(view, insets: .init(top: Constants.cellDefaultMargin, + left: Constants.cellDefaultMargin, + bottom: Constants.cellDefaultMargin, + right: Constants.cellDefaultMargin)) + cell.separatorInset = .zero + cell.selectionStyle = .none + } + private func configureSummary(cell: SummaryTableViewCell) { let cellViewModel = SummaryTableViewCellViewModel( order: order, @@ -1218,6 +1271,7 @@ extension OrderDetailsDataSource { siteShippingMethods = resultsControllers.siteShippingMethods productVariations = resultsControllers.productVariations shippingLabels = resultsControllers.shippingLabels + shipments = resultsControllers.shipments shippingLabelOrderItemsAggregator = AggregatedShippingLabelOrderItems( shippingLabels: shippingLabels, orderItems: items, @@ -1325,8 +1379,10 @@ extension OrderDetailsDataSource { return Section(category: .installWCShip, title: nil, rows: rows) }() + let wooShippingSection = createWooShippingSection() + let shippingLabelSections: [Section] = { - guard shippingLabels.isNotEmpty else { + guard wooShippingSection == nil, shippingLabels.isNotEmpty else { return [] } @@ -1516,7 +1572,8 @@ extension OrderDetailsDataSource { customAmountsSection, customFields, installWCShipSection, - refundedProducts + refundedProducts, + wooShippingSection ] + shippingLabelSections + [ subscriptions, shippingLinesSection, @@ -1530,6 +1587,17 @@ extension OrderDetailsDataSource { ] } + private func createWooShippingSection() -> Section? { + guard shipments.isNotEmpty else { + return nil + } + return Section( + category: .wooShipping, + title: Title.shippingLabels, + rows: shipments.map { _ in Row.shipmentDetails } + ) + } + private func createPaymentSection() -> Section { var rows: [Row] = [.payment, .customerPaid] if condensedRefunds.isNotEmpty { @@ -1804,6 +1872,11 @@ extension OrderDetailsDataSource { NSLocalizedString("Don’t know how to print from your mobile device?", comment: "Title of button in order details > shipping label that shows the instructions on how to print " + "a shipping label on the mobile device.") + static let shippingLabels = NSLocalizedString( + "orderDetailsDataSource.title.shippingLabels", + value: "Shipping Labels", + comment: "Title of Shipping Labels Section in Order Details screen." + ) static let orderAttribution = NSLocalizedString( "orderDetailsDataSource.attributionInfo.orderAttribution", value: "Order attribution", @@ -1846,6 +1919,7 @@ extension OrderDetailsDataSource { case shippingLabel case refundedProducts case payment + case wooShipping case customerInformation case subscriptions case giftCards @@ -1955,6 +2029,7 @@ extension OrderDetailsDataSource { case shippingLabelRefunded case shippingLabelReprintButton case shippingLabelTrackingNumber + case shipmentDetails case shippingLine case addOrderNote case orderNoteHeader @@ -2047,6 +2122,8 @@ extension OrderDetailsDataSource { return WooBasicTableViewCell.reuseIdentifier case .shippingLine: return HostingConfigurationTableViewCell.reuseIdentifier + case .shipmentDetails: + return HostingConfigurationTableViewCell.reuseIdentifier } } } @@ -2058,8 +2135,11 @@ extension OrderDetailsDataSource { case issueRefund case collectPayment case reprintShippingLabel(shippingLabel: ShippingLabel) - case createShippingLabel + case createShippingLabel(shipmentIndex: Int?) case openShippingLabelForm(shippingLabel: ShippingLabel) + case refundShippingLabel(shippingLabel: ShippingLabel) + case printCustomsForm(url: String) + case viewShipmentItems(shipment: WooShippingShipment) case shippingLabelTrackingMenu(shippingLabel: ShippingLabel, sourceView: UIView) case viewAddOns(addOns: [OrderItemProductAddOn]) case editCustomerNote diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShipmentDetailsView.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShipmentDetailsView.swift new file mode 100644 index 00000000000..b4f880e55b0 --- /dev/null +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsShipmentDetailsView.swift @@ -0,0 +1,222 @@ +import SwiftUI +import Yosemite + +struct OrderDetailsShipmentDetailsView: View { + let shipment: WooShippingShipment + let totalShipmentCount: Int + let eligibleForCreatingShippingLabel: Bool + + let onViewItems: () -> Void + let onCreateLabel: () -> Void + let onViewLabel: (ShippingLabel) -> Void + let onPrintLabel: (ShippingLabel) -> Void + let onPrintCustomsForm: (String) -> Void + let onRefund: (ShippingLabel) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: Layout.contentPadding) { + HStack { + Text(String.localizedStringWithFormat(Localization.shipmentFormat, shipmentIndex)) + .headlineStyle() + Image(systemName: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(Layout.checkColor) + .renderedIf(!canCreateLabel) + Spacer() + if let label = shipment.shippingLabel, label.refund == nil { + Menu { + Button(Localization.requestRefund) { + onRefund(label) + } + .renderedIf(label.isRefundable) + + if let url = label.commercialInvoiceURL, url.isNotEmpty { + Button(Localization.printCustomsForm) { + onPrintCustomsForm(url) + } + } + } label: { + Image(systemName: "ellipsis") + .foregroundStyle(Color.accentColor) + .fontWeight(.semibold) + } + } + } + .padding(.vertical, Layout.extraSpacing) + .accessibilityElement(children: .combine) + + if shipment.shippingLabel?.refund != nil { + Text(Localization.refundMessage) + .font(.subheadline) + .padding(Layout.contentPadding) + .background(Color.withColorStudio(name: .blue, shade: .shade5).opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: Layout.cornerRadius)) + } + + Divider() + .padding(.trailing, -Layout.contentPadding) + + Button(action: onViewItems) { + HStack { + Text(itemCountTitle) + Spacer() + Image(systemName: "chevron.forward") + .foregroundStyle(Color(.tertiaryLabel)) + .fontWeight(.semibold) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if canCreateLabel && eligibleForCreatingShippingLabel { + Divider() + .padding(.trailing, -Layout.contentPadding) + + Button(Localization.createShippingLabel, action: onCreateLabel) + .buttonStyle(PrimaryButtonStyle()) + .padding(.vertical, Layout.extraSpacing) + + } else if let shippingLabel = shipment.shippingLabel, shippingLabel.refund == nil { + Divider() + .padding(.trailing, -Layout.contentPadding) + + HStack { + Image(uiImage: .locationImage) + .renderingMode(.template) + .foregroundStyle(Color.accentColor) + VStack(alignment: .leading) { + Text(Localization.trackingNumber) + Text(shippingLabel.trackingNumber) + .subheadlineStyle() + } + .frame(maxWidth: .infinity, alignment: .leading) + Menu { + Button(Localization.copyTrackingNumber) { + shippingLabel.trackingNumber.sendToPasteboard(includeTrailingNewline: false) + } + } label: { + Image(systemName: "ellipsis") + .foregroundStyle(Color.accentColor) + .fontWeight(.semibold) + } + } + .accessibilityElement(children: .combine) + + Divider() + .padding(.trailing, -Layout.contentPadding) + + Button { + onViewLabel(shippingLabel) + } label: { + HStack { + Text(Localization.viewShippingLabel) + Spacer() + Image(systemName: "chevron.forward") + .foregroundStyle(Color(.tertiaryLabel)) + .fontWeight(.semibold) + } + } + .buttonStyle(.plain) + + Divider() + .padding(.trailing, -Layout.contentPadding) + + Button(String.localizedStringWithFormat(Localization.printShippingLabel, shipmentIndex)) { + onPrintLabel(shippingLabel) + } + .buttonStyle(SecondaryButtonStyle()) + .padding(.vertical, Layout.extraSpacing) + } + } + } +} + +private extension OrderDetailsShipmentDetailsView { + var shipmentIndex: String { + guard let intID = Int(shipment.index) else { + return shipment.index + } + return "\(intID + 1)/\(totalShipmentCount)" + } + + var itemCountTitle: String { + String.pluralize( + shipment.items.map { $0.quantity.intValue }.reduce(0, +), + singular: Localization.itemCountSingular, + plural: Localization.itemCountPlural + ) + } + + var canCreateLabel: Bool { + shipment.shippingLabel == nil || shipment.shippingLabel?.refund != nil + } +} + +private extension OrderDetailsShipmentDetailsView { + enum Layout { + static let contentPadding: CGFloat = 16 + static let cornerRadius: CGFloat = 8 + static let extraSpacing: CGFloat = 8 + static let checkColor = Color(light: .withColorStudio(name: .green, shade: .shade70), + dark: .withColorStudio(name: .green, shade: .shade50)) + } + enum Localization { + static let shipmentFormat = NSLocalizedString( + "orderDetailsShipmentDetailsView.title", + value: "Shipment %1$@", + comment: "Order shipment title format. The placeholder indicates the index of the shipping label package." + ) + static let requestRefund = NSLocalizedString( + "orderDetailsShipmentDetailsView.requestRefund", + value: "Request a refund", + comment: "Button to request a refund for a purchased shipping label." + ) + static let printCustomsForm = NSLocalizedString( + "orderDetailsShipmentDetailsView.printCustomsForm", + value: "Print customs form", + comment: "Button to print the customs form for a purchased shipping label." + ) + static let refundMessage = NSLocalizedString( + "orderDetailsShipmentDetailsView.refundMessage", + value: "You have successfully submitted a request for refund. " + + "You can purchase a new label.", + comment: "Message for a refunded shipping label." + ) + static let itemCountSingular = NSLocalizedString( + "orderDetailsShipmentDetailsView.itemCountSingular", + value: "%1$d item", + comment: "Singular item count for a shipment. Reads like: 1 item" + ) + static let itemCountPlural = NSLocalizedString( + "orderDetailsShipmentDetailsView.itemCountPlural", + value: "%1$d items", + comment: "Plural item count for a shipment. Reads like: 2 items" + ) + static let createShippingLabel = NSLocalizedString( + "orderDetailsShipmentDetailsView.createShippingLabel", + value: "Create Shipping Label", + comment: "Button to create a shipping label for a shipment" + ) + static let printShippingLabel = NSLocalizedString( + "orderDetailsShipmentDetailsView.printShippingLabel", + value: "Print Shipping Label (%1$@)", + comment: "Button to print a shipping label for a shipment. Placeholder is the shipment index. " + + "Reads like: Print Shipping Label (1/2)" + ) + static let trackingNumber = NSLocalizedString( + "orderDetailsShipmentDetailsView.trackingNumber", + value: "Tracking number", + comment: "Title for the tracking number of a shipping label" + ) + static let copyTrackingNumber = NSLocalizedString( + "orderDetailsShipmentDetailsView.copyTrackingNumber", + value: "Copy tracking number", + comment: "Button to copy the tracking number of a shipping label" + ) + static let viewShippingLabel = NSLocalizedString( + "orderDetailsShipmentDetailsView.viewShippingLabel", + value: "View purchased shipping label", + comment: "Button to view details of a shipping label" + ) + } +} diff --git a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift index 3ad3f05eb6d..b9c9e3e7798 100644 --- a/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift +++ b/WooCommerce/Classes/ViewModels/Order Details/OrderDetailsViewModel.swift @@ -439,7 +439,8 @@ extension OrderDetailsViewModel { ] let cellsWithoutNib = [ - HostingConfigurationTableViewCell.self + HostingConfigurationTableViewCell.self, + HostingConfigurationTableViewCell.self, ] for cellClass in cellsWithNib { @@ -995,7 +996,7 @@ private extension OrderDetailsViewModel { func syncShipmentsForWooShipping() { stores.dispatch(WooShippingAction.syncShipments(siteID: order.siteID, orderID: order.orderID) { result in switch result { - case .success(let shipments): + case .success: ServiceLocator.analytics.track(event: .shippingLabelsAPIRequest( result: .success, isRevampedFlow: true diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/OrderDetailsViewController.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/OrderDetailsViewController.swift index 0037a065b66..e42e10be129 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/OrderDetailsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/OrderDetailsViewController.swift @@ -399,10 +399,20 @@ private extension OrderDetailsViewController { let printViewController = coordinator.createPrintViewController() printNavigationController.viewControllers = [printViewController] present(printNavigationController, animated: true) - case .createShippingLabel: - navigateToCreateShippingLabelForm() + case .createShippingLabel(let shipmentIndex): + let preselection: WooShippingCreateLabelSelection? = { + guard let shipmentIndex else { return nil } + return .shipment(index: shipmentIndex) + }() + navigateToCreateShippingLabelForm(preSelection: preselection) case .openShippingLabelForm(let shippingLabel): - navigateToCreateShippingLabelForm(shippingLabel: shippingLabel) + navigateToCreateShippingLabelForm(preSelection: .shippingLabel(label: shippingLabel)) + case .viewShipmentItems(let shipment): + showShipmentItems(shipment: shipment) + case .refundShippingLabel(let shippingLabel): + refundShippingLabel(shippingLabel) + case .printCustomsForm(let url): + printCustomsForm(url: url) case .shippingLabelTrackingMenu(let shippingLabel, let sourceView): shippingLabelTrackingMoreMenuTapped(shippingLabel: shippingLabel, sourceView: sourceView) case let .viewAddOns(addOns): @@ -416,7 +426,7 @@ private extension OrderDetailsViewController { } } - func navigateToCreateShippingLabelForm(shippingLabel: ShippingLabel? = nil) { + func navigateToCreateShippingLabelForm(preSelection: WooShippingCreateLabelSelection? = nil) { guard viewModel.dataSource.isEligibleForWooShipping else { // Navigate to legacy shipping label creation form if Woo Shipping extension is not supported. let shippingLabelFormVC = ShippingLabelFormViewController(order: viewModel.order) @@ -446,7 +456,7 @@ private extension OrderDetailsViewController { } let shippingLabelCreationVM = WooShippingCreateLabelsViewModel(order: viewModel.order, - selectedShippingLabel: shippingLabel, + preselection: preSelection, onLabelPurchase: { [weak self] markOrderComplete in if markOrderComplete { self?.markOrderCompleteFromShippingLabels() @@ -543,42 +553,13 @@ private extension OrderDetailsViewController { if shippingLabel.isRefundable { actionSheet.addDefaultActionWithTitle(Localization.ShippingLabelMoreMenu.requestRefundAction) { [weak self] _ in - guard ServiceLocator.featureFlagService.isFeatureFlagEnabled(.revampedShippingLabelCreation) else { - let refundViewController = RefundShippingLabelViewController(shippingLabel: shippingLabel) { [weak self] in - self?.navigationController?.popViewController(animated: true) - } - // Disables the bottom bar (tab bar) when requesting a refund. - refundViewController.hidesBottomBarWhenPushed = true - self?.show(refundViewController, sender: self) - return - } - - let refundViewModel = WooShippingRefundViewModel(shippingLabel: shippingLabel) - let view = WooShippingRefundView(viewModel: refundViewModel) { [weak self] updatedLabel in - guard let self else { return } - presentedViewController?.dismiss(animated: true) - - var allLabels = viewModel.order.shippingLabels - guard let index = allLabels.firstIndex(where: { $0.shippingLabelID == updatedLabel.shippingLabelID }) else { - return - } - allLabels[index] = updatedLabel - let updatedOrder = viewModel.order.copy(shippingLabels: allLabels) - - viewModel.update(order: updatedOrder) - reloadTableViewSectionsAndData() - } - let refundViewController = UIHostingController(rootView: view) - self?.present(refundViewController, animated: true) + self?.refundShippingLabel(shippingLabel) } } if let url = shippingLabel.commercialInvoiceURL, url.isNotEmpty { actionSheet.addDefaultActionWithTitle(Localization.ShippingLabelMoreMenu.printCustomsFormAction) { [weak self] _ in - let printCustomsFormsView = PrintCustomsFormsView(invoiceURLs: [url]) - let hostingController = UIHostingController(rootView: printCustomsFormsView) - hostingController.hidesBottomBarWhenPushed = true - self?.show(hostingController, sender: self) + self?.printCustomsForm(url: url) } } @@ -588,6 +569,53 @@ private extension OrderDetailsViewController { present(actionSheet, animated: true) } + func refundShippingLabel(_ shippingLabel: ShippingLabel) { + guard ServiceLocator.featureFlagService.isFeatureFlagEnabled(.revampedShippingLabelCreation) else { + let refundViewController = RefundShippingLabelViewController(shippingLabel: shippingLabel) { [weak self] in + self?.navigationController?.popViewController(animated: true) + } + // Disables the bottom bar (tab bar) when requesting a refund. + refundViewController.hidesBottomBarWhenPushed = true + show(refundViewController, sender: self) + return + } + + let refundViewModel = WooShippingRefundViewModel(shippingLabel: shippingLabel) + let view = WooShippingRefundView(viewModel: refundViewModel) { [weak self] updatedLabel in + guard let self else { return } + presentedViewController?.dismiss(animated: true) + + var allLabels = viewModel.order.shippingLabels + guard let index = allLabels.firstIndex(where: { $0.shippingLabelID == updatedLabel.shippingLabelID }) else { + return + } + allLabels[index] = updatedLabel + let updatedOrder = viewModel.order.copy(shippingLabels: allLabels) + + viewModel.update(order: updatedOrder) + reloadTableViewSectionsAndData() + } + let refundViewController = UIHostingController(rootView: view) + present(refundViewController, animated: true) + } + + func printCustomsForm(url: String) { + let printCustomsFormsView = PrintCustomsFormsView(invoiceURLs: [url]) + let hostingController = UIHostingController(rootView: printCustomsFormsView) + hostingController.hidesBottomBarWhenPushed = true + show(hostingController, sender: self) + } + + func showShipmentItems(shipment: WooShippingShipment) { + let items = shipment.items.compactMap { item in + let orderItem = viewModel.order.items.first(where: { $0.itemID == item.id }) + return orderItem?.copy(quantity: item.quantity) + } + let aggregateOrderItem = AggregateDataHelper.combineOrderItems(items, with: []) + let productListVC = AggregatedProductListViewController(viewModel: viewModel, items: aggregateOrderItem) + show(productListVC, sender: nil) + } + func shippingLabelTrackingMoreMenuTapped(shippingLabel: ShippingLabel, sourceView: UIView) { let actionSheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) actionSheet.view.tintColor = .text diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Split Shipments/WooShippingSplitShipmentsViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Split Shipments/WooShippingSplitShipmentsViewModel.swift index 3176ef1ac83..eee02e48946 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Split Shipments/WooShippingSplitShipmentsViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShipping Split Shipments/WooShippingSplitShipmentsViewModel.swift @@ -549,13 +549,12 @@ extension WooShippingSplitShipmentsViewModel { .map { shipment in var shipmentContents = ShipmentContents() for shipmentItem in shipment.items { - guard let packageItem = packageItems.first(where: { $0.orderItemID == shipmentItem.id }), - let subItems = shipmentItem.subItems else { + guard let packageItem = packageItems.first(where: { $0.orderItemID == shipmentItem.id }) else { continue } - let quantity = subItems.count > 0 ? subItems.count : 1 - let updatedItem = ShippingLabelPackageItem(copy: packageItem, quantity: Decimal(quantity)) + let updatedItem = ShippingLabelPackageItem(copy: packageItem, + quantity: shipmentItem.quantity) let content = CollapsibleShipmentItemCardViewModel(item: updatedItem, isSelectable: shipment.shippingLabel == nil, currency: currency) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModel.swift index b57c3954e32..7a644cd10a5 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Shipping Labels/WooShipping Create Shipping Labels/WooShippingCreateLabelsViewModel.swift @@ -5,6 +5,18 @@ import Combine import struct Networking.WooShippingAccountSettings import enum Networking.DotcomError +enum WooShippingCreateLabelSelection { + case shipment(index: Int) + case shippingLabel(label: ShippingLabel) + + var selectedShippingLabel: ShippingLabel? { + switch self { + case .shipment: nil + case .shippingLabel(let label): label + } + } +} + /// Provides view data for `WooShippingCreateLabelsView`. /// final class WooShippingCreateLabelsViewModel: ObservableObject { @@ -195,7 +207,7 @@ final class WooShippingCreateLabelsViewModel: ObservableObject { /// Initialize the view model with or without an existing shipping label. init(order: Order, - selectedShippingLabel: ShippingLabel? = nil, + preselection: WooShippingCreateLabelSelection? = nil, currencySettings: CurrencySettings = ServiceLocator.currencySettings, shippingSettingsService: ShippingSettingsService = ServiceLocator.shippingSettingsService, stores: StoresManager = ServiceLocator.stores, @@ -221,10 +233,10 @@ final class WooShippingCreateLabelsViewModel: ObservableObject { self.splitShipmentsViewModel = splitShipmentsViewModel self.shipments = splitShipmentsViewModel.shipments - if let selectedShippingLabel { + if let label = preselection?.selectedShippingLabel { destinationAddressStatus = .verified - destinationAddress = selectedShippingLabel.destinationAddress.toWooShippingAddress() - originAddress = selectedShippingLabel.originAddress.formattedInlineAddress ?? "" + destinationAddress = label.destinationAddress.toWooShippingAddress() + originAddress = label.originAddress.formattedInlineAddress ?? "" } else { destinationAddress = getDestinationAddress(order: order, address: order.shippingAddress) loadDestinationAddress() @@ -240,11 +252,18 @@ final class WooShippingCreateLabelsViewModel: ObservableObject { Task { @MainActor in await loadRequiredData() - // After shipment configs are updated, shipments are updated with purchased label details - // Update the selected tab now by comparing the purchased labels with the initial selected label. - if let selectedShippingLabel, - let matchingIndex = shipments.firstIndex(where: { $0.purchasedLabel?.shippingLabelID == selectedShippingLabel.shippingLabelID }) { - selectedShipmentIndex = matchingIndex + // After shipment configs are updated, shipments are updated with purchased label details. + // Update the selected tab now by checking the initial selected shipment index if available. + // Otherwise, compare the purchased labels with the initial selected label. + switch preselection { + case .shipment(let index): + self.selectedShipmentIndex = index + case .shippingLabel(let label): + if let matchingIndex = shipments.firstIndex(where: { $0.purchasedLabel?.shippingLabelID == label.shippingLabelID }) { + self.selectedShipmentIndex = matchingIndex + } + case .none: + break } } } diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index bfab2af03f0..227e359a65c 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -2733,6 +2733,7 @@ DE8AA0B12BBE50CF0084D2CC /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8AA0B02BBE50CF0084D2CC /* DashboardView.swift */; }; DE8AA0B32BBE55E40084D2CC /* DashboardViewHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8AA0B22BBE55E40084D2CC /* DashboardViewHostingController.swift */; }; DE8AA0B52BBEBE590084D2CC /* ViewControllerContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8AA0B42BBEBE590084D2CC /* ViewControllerContainer.swift */; }; + DE8C63AE2E1E2D2D00DA48AC /* OrderDetailsShipmentDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8C63AD2E1E2D1400DA48AC /* OrderDetailsShipmentDetailsView.swift */; }; DE8C946E264699B600C94823 /* PluginListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE8C946D264699B600C94823 /* PluginListViewModel.swift */; }; DE96844B2A331AD2000FBF4E /* WooAnalyticsEvent+ProductSharingAI.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE96844A2A331AD2000FBF4E /* WooAnalyticsEvent+ProductSharingAI.swift */; }; DE96844D2A332CC2000FBF4E /* ShareProductCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE96844C2A332CC2000FBF4E /* ShareProductCoordinatorTests.swift */; }; @@ -5913,6 +5914,7 @@ DE8AA0B02BBE50CF0084D2CC /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; DE8AA0B22BBE55E40084D2CC /* DashboardViewHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewHostingController.swift; sourceTree = ""; }; DE8AA0B42BBEBE590084D2CC /* ViewControllerContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewControllerContainer.swift; sourceTree = ""; }; + DE8C63AD2E1E2D1400DA48AC /* OrderDetailsShipmentDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderDetailsShipmentDetailsView.swift; sourceTree = ""; }; DE8C946D264699B600C94823 /* PluginListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginListViewModel.swift; sourceTree = ""; }; DE96844A2A331AD2000FBF4E /* WooAnalyticsEvent+ProductSharingAI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooAnalyticsEvent+ProductSharingAI.swift"; sourceTree = ""; }; DE96844C2A332CC2000FBF4E /* ShareProductCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareProductCoordinatorTests.swift; sourceTree = ""; }; @@ -12846,6 +12848,7 @@ D817585D22BB5E8700289CFE /* OrderEmailComposer.swift */, D817586122BB64C300289CFE /* OrderDetailsNotices.swift */, D817586322BDD81600289CFE /* OrderDetailsDataSource.swift */, + DE8C63AD2E1E2D1400DA48AC /* OrderDetailsShipmentDetailsView.swift */, D8C11A4D22DD235F00D4A88D /* OrderDetailsResultsControllers.swift */, D8C11A5D22E2440400D4A88D /* OrderPaymentDetailsViewModel.swift */, 31316F9B25CB20FD00D9F129 /* OrderStatusListViewModel.swift */, @@ -16671,6 +16674,7 @@ DE792E1826EF35F40071200C /* ConnectivityObserver.swift in Sources */, CE9F60122C09D53500652E0A /* FeedbackBannerPopover.swift in Sources */, B9B0391628A6824200DC1C83 /* PermanentNoticePresenter.swift in Sources */, + DE8C63AE2E1E2D2D00DA48AC /* OrderDetailsShipmentDetailsView.swift in Sources */, 45693189265403A1009ED69D /* ShippingLabelCarriersViewModel.swift in Sources */, 0211254125778BDF0075AD2A /* ShippingLabelDetailsViewController.swift in Sources */, 860476E12B6A31D500AF0AEB /* ManualProductTypeOptions.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Details/OrderDetailsDataSourceTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Details/OrderDetailsDataSourceTests.swift index 99d5ea530f9..efeecab57c7 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Details/OrderDetailsDataSourceTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Details/OrderDetailsDataSourceTests.swift @@ -1028,6 +1028,44 @@ final class OrderDetailsDataSourceTests: XCTestCase { XCTAssertEqual(secondLabelSection.title, "Package 2") let secondLabel = dataSource.shippingLabel(at: shippingLabelSectionsIndices[1]) XCTAssertEqual(secondLabel, shippingLabel1) + + let shipmentsSection = dataSource.sections.first { $0.category == .wooShipping } + XCTAssertNil(shipmentsSection) + } + + func test_purchased_shipping_labels_are_unified_in_a_single_section_if_shipments_are_available() throws { + // Given + var order = makeOrder() + let shippingLabel1 = ShippingLabel.fake().copy(siteID: order.siteID, orderID: order.orderID, shipmentID: "1") + let shippingLabel2 = ShippingLabel.fake().copy(siteID: order.siteID, orderID: order.orderID, shipmentID: "0") + order = order.copy(shippingLabels: [shippingLabel1, shippingLabel2]) + let shipment1 = WooShippingShipment.fake().copy(siteID: order.siteID, orderID: order.orderID, index: "1") + let shipment2 = WooShippingShipment.fake().copy(siteID: order.siteID, orderID: order.orderID, index: "0") + insert(shipment: shipment1, shippingLabel: shippingLabel1, order: order) + insert(shipment: shipment2, shippingLabel: shippingLabel2, order: order) + + let dataSource = OrderDetailsDataSource(order: order, + storageManager: storageManager, + cardPresentPaymentsConfiguration: Mocks.configuration, + receiptEligibilityUseCase: MockReceiptEligibilityUseCase(), + featureFlags: MockFeatureFlagService(revampedShippingLabelCreation: false)) + dataSource.configureResultsControllers { } + + // When + dataSource.reloadSections() + + // Then + // Get IndexPaths for all shipping label rows + var shippingLabelSectionsIndices: [IndexPath] = [] + for (sectionIndex, section) in dataSource.sections.enumerated() { + for (rowIndex, row) in section.rows.enumerated() where row == .shippingLabelDetail { + shippingLabelSectionsIndices.append(IndexPath(row: rowIndex, section: sectionIndex)) + } + } + XCTAssertEqual(shippingLabelSectionsIndices.count, 0) + + let shipmentsSection = dataSource.sections.first { $0.category == .wooShipping } + XCTAssertEqual(shipmentsSection?.rows.count, 2) } func test_isEligibleForBackendReceipt_when_initialized_then_defaults_to_false() { @@ -1214,6 +1252,25 @@ private extension OrderDetailsDataSourceTests { storageShippingLabel.order = storageOrder } + func insert(shipment: WooShippingShipment, shippingLabel: ShippingLabel, order: Order) { + let storageOrder = storage.insertNewObject(ofType: StorageOrder.self) + storageOrder.update(with: order) + + let storageShipment = storage.insertNewObject(ofType: StorageWooShippingShipment.self) + storageShipment.update(with: shipment) + storageShipment.order = storageOrder + + let storageShippingLabel = storage.insertNewObject(ofType: StorageShippingLabel.self) + storageShippingLabel.update(with: shippingLabel) + if let shippingLabelRefund = shippingLabel.refund { + let storageRefund = storage.insertNewObject(ofType: StorageShippingLabelRefund.self) + storageRefund.update(with: shippingLabelRefund) + storageShippingLabel.refund = storageRefund + } + storageShippingLabel.order = storageOrder + storageShippingLabel.shipment = storageShipment + } + func insert(_ readOnlyPlugin: Yosemite.SitePlugin) { let plugin = storage.insertNewObject(ofType: StorageSitePlugin.self) plugin.update(with: readOnlyPlugin)