Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Modules/Sources/Yosemite/Tools/EntityListener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ public class EntityListener<T: ReadOnlyType> {
self.init(viewContext: storageManager.persistentContainer.viewContext,
readOnlyEntity: readOnlyEntity)
}

deinit {
if let token = notificationsToken {
NotificationCenter.default.removeObserver(token)
}
}
}


Expand Down
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import CoreData
import Yosemite
import protocol Storage.StorageManagerType
import protocol WooFoundation.Analytics
Expand Down Expand Up @@ -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<Order>] = [:]

var status: String {
selectedOrderStatus?.description ?? Localization.anyStatusCase
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we check only for relevant updates? Changes like custom fields and customer notes should not affect the row on the card. Maybe check for changes in status, customer name, and cost - basically things that are displayed on the card only?

Also, should we reload the card to get the latest orders if status is changed? Otherwise we'll show orders with mismatched status like this:

Simulator.Screen.Recording.-.iPhone.17.-.2025-12-11.at.09.33.03.mov

}

func removeRow(with orderID: Int64) {
rows.removeAll { $0.id == orderID }
orderEntityListeners[orderID] = nil
Copy link
Contributor

@itsmeichigo itsmeichigo Dec 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar comment as above - we should reload the card to get 3 last orders. If all 3 orders are trashed, the card will be left with no item even though there might be more.

}
}

// MARK: Constants
//
private extension LastOrdersDashboardCardViewModel {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -112,7 +112,7 @@ final class LastOrdersDashboardCardViewModelTests: XCTestCase {
let viewModel = LastOrdersDashboardCardViewModel(siteID: sampleSiteID,
stores: stores,
storageManager: storageManager)
insertOrderStatuses(sampleOrderStatuses)
await insertOrderStatuses(sampleOrderStatuses)

mockFetchFilteredOrders()
mockOrderStatuses()
Expand Down Expand Up @@ -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() {
Expand All @@ -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)
}
}