diff --git a/Modules/Sources/Yosemite/Tools/EntityListener.swift b/Modules/Sources/Yosemite/Tools/EntityListener.swift index 6bdd7db154a..7403e46507a 100644 --- a/Modules/Sources/Yosemite/Tools/EntityListener.swift +++ b/Modules/Sources/Yosemite/Tools/EntityListener.swift @@ -47,6 +47,12 @@ public class EntityListener { self.init(viewContext: storageManager.persistentContainer.viewContext, readOnlyEntity: readOnlyEntity) } + + deinit { + if let token = notificationsToken { + NotificationCenter.default.removeObserver(token) + } + } } diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 0e39fbe3aec..96fa9e12de5 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -7,6 +7,7 @@ - [Internal] Moved the request for push notification authorization after login [https://github.com/woocommerce/woocommerce-ios/pull/16428] - [Internal] Cleaned up logic for push notification token registration [https://github.com/woocommerce/woocommerce-ios/pull/16434] - [*] Fixed possible sync issue in POS (https://github.com/woocommerce/woocommerce-ios/pull/16423) +- [*] Keep order statuses in "Most recent orders" dashboard card up to date (https://github.com/woocommerce/woocommerce-ios/pull/16442) 23.8 ----- diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Orders/LastOrdersDashboardCardViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Orders/LastOrdersDashboardCardViewModel.swift index ebf1ad34d8e..444f4013d24 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Orders/LastOrdersDashboardCardViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Orders/LastOrdersDashboardCardViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import CoreData import Yosemite import protocol Storage.StorageManagerType import protocol WooFoundation.Analytics @@ -89,6 +90,8 @@ final class LastOrdersDashboardCardViewModel: ObservableObject { @Published private(set) var rows: [LastOrderDashboardRowViewModel] = [] @Published private(set) var allStatuses: [OrderStatusRow] = [] + private var orderEntityListeners: [Int64: EntityListener] = [:] + var status: String { selectedOrderStatus?.description ?? Localization.anyStatusCase } @@ -127,12 +130,15 @@ final class LastOrdersDashboardCardViewModel: ObservableObject { syncingData = true syncingError = nil rows = [] + tearDownOrderEntityListeners() do { async let orders = loadLast3Orders(for: selectedOrderStatus) try? await loadOrderStatuses() - rows = try await orders + let fetchedOrders = try await orders + rows = fetchedOrders .map { LastOrderDashboardRowViewModel(order: $0) } + setupOrderEntityListeners(for: fetchedOrders) analytics.track(event: .DynamicDashboard.cardLoadingCompleted(type: .lastOrders)) } catch { syncingError = error @@ -241,6 +247,48 @@ private extension LastOrdersDashboardCardViewModel { } } +// MARK: Orders observing +// +private extension LastOrdersDashboardCardViewModel { + func setupOrderEntityListeners(for orders: [Order]) { + tearDownOrderEntityListeners() + guard let viewContext = storageManager.viewStorage as? NSManagedObjectContext else { + return + } + + for order in orders { + let listener = EntityListener(viewContext: viewContext, readOnlyEntity: order) + listener.onUpsert = { updatedOrder in + Task { @MainActor [weak self] in + self?.updateRow(with: updatedOrder) + } + } + listener.onDelete = { + Task { @MainActor [weak self] in + self?.removeRow(with: order.orderID) + } + } + orderEntityListeners[order.orderID] = listener + } + } + + func tearDownOrderEntityListeners() { + orderEntityListeners.removeAll() + } + + func updateRow(with order: Order) { + guard let index = rows.firstIndex(where: { $0.id == order.orderID }) else { + return + } + rows[index] = LastOrderDashboardRowViewModel(order: order) + } + + func removeRow(with orderID: Int64) { + rows.removeAll { $0.id == orderID } + orderEntityListeners[orderID] = nil + } +} + // MARK: Constants // private extension LastOrdersDashboardCardViewModel { diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Orders/LastOrdersDashboardCardViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Orders/LastOrdersDashboardCardViewModelTests.swift index 93b23756feb..5c68a0a0ce7 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Orders/LastOrdersDashboardCardViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Orders/LastOrdersDashboardCardViewModelTests.swift @@ -1,8 +1,8 @@ import XCTest +import CoreData import Yosemite +import Storage @testable import WooCommerce -import protocol Storage.StorageManagerType -import protocol Storage.StorageType final class LastOrdersDashboardCardViewModelTests: XCTestCase { private let sampleSiteID: Int64 = 134 @@ -112,7 +112,7 @@ final class LastOrdersDashboardCardViewModelTests: XCTestCase { let viewModel = LastOrdersDashboardCardViewModel(siteID: sampleSiteID, stores: stores, storageManager: storageManager) - insertOrderStatuses(sampleOrderStatuses) + await insertOrderStatuses(sampleOrderStatuses) mockFetchFilteredOrders() mockOrderStatuses() @@ -201,15 +201,59 @@ final class LastOrdersDashboardCardViewModelTests: XCTestCase { } await viewModel.reloadData() } + + @MainActor + func test_rows_are_updated_when_storage_order_is_upserted() async throws { + // Given + let viewModel = LastOrdersDashboardCardViewModel(siteID: sampleSiteID, + stores: stores, + storageManager: storageManager) + mockFetchFilteredOrders() + mockOrderStatuses() + await viewModel.reloadData() + XCTAssertEqual(viewModel.rows.first?.statusDescription, sampleOrders[0].status.description) + + // When + let updatedStatus: OrderStatusEnum = .onHold + await upsertStorageOrder(sampleOrders[0].copy(status: updatedStatus)) + try await Task.sleep(nanoseconds: 50_000_000) // allow async Task in listener to run + + // Then + XCTAssertEqual(viewModel.rows.first?.statusDescription, updatedStatus.description) + } + + @MainActor + func test_row_is_removed_when_storage_order_is_deleted() async throws { + // Given + let viewModel = LastOrdersDashboardCardViewModel(siteID: sampleSiteID, + stores: stores, + storageManager: storageManager) + mockFetchFilteredOrders() + mockOrderStatuses() + await viewModel.reloadData() + XCTAssertEqual(viewModel.rows.count, 3) + + // Ensure order exists in storage so deletion triggers listener + await upsertStorageOrder(sampleOrders[0]) + + // When + await deleteStorageOrder(siteID: sampleSiteID, orderID: sampleOrders[0].orderID) + try await Task.sleep(nanoseconds: 50_000_000) // allow async Task in listener to run + + // Then + XCTAssertEqual(viewModel.rows.count, 2) + XCTAssertFalse(viewModel.rows.contains(where: { $0.id == sampleOrders[0].orderID })) + } } private extension LastOrdersDashboardCardViewModelTests { - func insertOrderStatuses(_ readOnlyOrderStatuses: [OrderStatus]) { - readOnlyOrderStatuses.forEach { orderStatus in - let newOrderStatus = storage.insertNewObject(ofType: StorageOrderStatus.self) - newOrderStatus.update(with: orderStatus) - } - storage.saveIfNeeded() + func insertOrderStatuses(_ readOnlyOrderStatuses: [Yosemite.OrderStatus]) async { + await storageManager.performAndSaveAsync({ storage in + readOnlyOrderStatuses.forEach { orderStatus in + let newOrderStatus = storage.insertNewObject(ofType: StorageOrderStatus.self) + newOrderStatus.update(with: orderStatus) + } + }, on: .main) } func mockFetchFilteredOrders() { @@ -233,4 +277,20 @@ private extension LastOrdersDashboardCardViewModelTests { } } } + + func upsertStorageOrder(_ order: Yosemite.Order) async { + await storageManager.performAndSaveAsync({ storage in + let storageOrder = storage.loadOrder(siteID: order.siteID, orderID: order.orderID) ?? storage.insertNewObject(ofType: Storage.Order.self) + storageOrder.update(with: order) + }, on: .main) + } + + func deleteStorageOrder(siteID: Int64, orderID: Int64) async { + await storageManager.performAndSaveAsync({ storage in + guard let storageOrder = storage.loadOrder(siteID: siteID, orderID: orderID) else { + return + } + storage.deleteObject(storageOrder) + }, on: .main) + } }