Skip to content

Commit 9e196ea

Browse files
authored
Top Performers Card: Observe opened product and reload stats upon changes (#16380)
2 parents c9bd3bb + acfcb36 commit 9e196ea

File tree

3 files changed

+148
-0
lines changed

3 files changed

+148
-0
lines changed

Modules/Sources/Networking/Model/Stats/TopEarnerStatsItem.swift

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,91 @@ extension TopEarnerStatsItem: Identifiable {
7373
productID
7474
}
7575
}
76+
77+
// MARK: - Helper to init Product
78+
//
79+
public extension Product {
80+
init(siteID: Int64,
81+
productID: Int64,
82+
name: String,
83+
images: [ProductImage]) {
84+
self.init(siteID: siteID,
85+
productID: productID,
86+
name: name,
87+
slug: "",
88+
permalink: "",
89+
date: Date(),
90+
dateCreated: Date(),
91+
dateModified: nil,
92+
dateOnSaleStart: nil,
93+
dateOnSaleEnd: nil,
94+
productTypeKey: ProductType.simple.rawValue,
95+
statusKey: ProductStatus.draft.rawValue,
96+
featured: false,
97+
catalogVisibilityKey: ProductCatalogVisibility.visible.rawValue,
98+
fullDescription: "",
99+
shortDescription: "",
100+
sku: "",
101+
globalUniqueID: "",
102+
price: "",
103+
regularPrice: "",
104+
salePrice: "",
105+
onSale: false,
106+
purchasable: false,
107+
totalSales: 0,
108+
virtual: false,
109+
downloadable: false,
110+
downloads: [],
111+
downloadLimit: -1,
112+
downloadExpiry: -1,
113+
buttonText: "",
114+
externalURL: "",
115+
taxStatusKey: ProductTaxStatus.taxable.rawValue,
116+
taxClass: "",
117+
manageStock: false,
118+
stockQuantity: nil,
119+
stockStatusKey: ProductStockStatus.inStock.rawValue,
120+
backordersKey: ProductBackordersSetting.notAllowed.rawValue,
121+
backordersAllowed: false,
122+
backordered: false,
123+
soldIndividually: false,
124+
weight: "",
125+
dimensions: ProductDimensions(length: "", width: "", height: ""),
126+
shippingRequired: true,
127+
shippingTaxable: true,
128+
shippingClass: "",
129+
shippingClassID: 0,
130+
productShippingClass: nil,
131+
reviewsAllowed: true,
132+
averageRating: "",
133+
ratingCount: 0,
134+
relatedIDs: [],
135+
upsellIDs: [],
136+
crossSellIDs: [],
137+
parentID: 0,
138+
purchaseNote: "",
139+
categories: [],
140+
tags: [],
141+
images: images,
142+
attributes: [],
143+
defaultAttributes: [],
144+
variations: [],
145+
groupedProducts: [],
146+
menuOrder: 0,
147+
addOns: [],
148+
isSampleItem: false,
149+
bundleStockStatus: nil,
150+
bundleStockQuantity: nil,
151+
bundleMinSize: nil,
152+
bundleMaxSize: nil,
153+
bundledItems: [],
154+
password: nil,
155+
compositeComponents: [],
156+
subscription: nil,
157+
minAllowedQuantity: nil,
158+
maxAllowedQuantity: nil,
159+
groupOfQuantity: nil,
160+
combineVariationQuantities: nil,
161+
customFields: [])
162+
}
163+
}

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
-----
66
- [*] Better support site credential login for sites with captcha plugins [https://github.com/woocommerce/woocommerce-ios/pull/16372]
77
- [*] Crash fix attempt to resolve the race condition in request authenticator swapping [https://github.com/woocommerce/woocommerce-ios/pull/16370]
8+
- [*] Update products on the Top Performers card when there are changes made to displayed products [https://github.com/woocommerce/woocommerce-ios/pull/16380]
89
- [Internal] Fix broken navigation to a variable product selector [https://github.com/woocommerce/woocommerce-ios/pull/16363]
910

1011
23.7

WooCommerce/Classes/ViewRelated/Dashboard/TopPerformers/TopPerformersDashboardViewModel.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ final class TopPerformersDashboardViewModel: ObservableObject {
3131

3232
private var resultsController: ResultsController<StorageTopEarnerStats>?
3333

34+
// Map of product IDs and their listeners
35+
private var entityListeners: [Int64: EntityListener<Product>] = [:]
36+
37+
private var hasProductChanges = PassthroughSubject<Void, Never>()
38+
3439
private var currentDate: Date {
3540
Date()
3641
}
@@ -83,6 +88,7 @@ final class TopPerformersDashboardViewModel: ObservableObject {
8388
self.usageTracksEventEmitter = usageTracksEventEmitter
8489

8590
observeSyncingCompletion()
91+
observeProductChanges()
8692

8793
Task { @MainActor in
8894
self.timeRange = await loadLastTimeRange() ?? .today
@@ -215,6 +221,57 @@ private extension TopPerformersDashboardViewModel {
215221
.store(in: &subscriptions)
216222
}
217223

224+
func observeProductChanges() {
225+
hasProductChanges
226+
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
227+
.sink { [weak self] in
228+
Task {
229+
await self?.reloadDataIfNeeded(forceRefresh: true)
230+
}
231+
}
232+
.store(in: &subscriptions)
233+
}
234+
235+
func observeProducts(for items: [TopEarnerStatsItem]) {
236+
let ids = items.map { $0.productID }
237+
entityListeners = entityListeners.filter { ids.contains($0.key) }
238+
for item in items {
239+
if entityListeners[item.productID] == nil {
240+
let listener = createProductEntityListener(for: item)
241+
entityListeners[item.productID] = listener
242+
}
243+
}
244+
}
245+
246+
func createProductEntityListener(for item: TopEarnerStatsItem) -> EntityListener<Product> {
247+
/// Mock product item out of details from top stat item
248+
var product = Product(
249+
siteID: siteID,
250+
productID: item.productID,
251+
name: item.productName ?? "",
252+
images: [ProductImage(
253+
imageID: -1,
254+
dateCreated: Date(),
255+
dateModified: nil,
256+
src: item.imageUrl ?? "",
257+
name: nil,
258+
alt: nil)
259+
]
260+
)
261+
let entityListener = EntityListener(storageManager: ServiceLocator.storageManager, readOnlyEntity: product)
262+
entityListener.onUpsert = { [weak self] updatedProduct in
263+
// reload stats if there are changes to product
264+
guard let self,
265+
updatedProduct.name != product.name ||
266+
updatedProduct.imageURL != product.imageURL else {
267+
return
268+
}
269+
product = updatedProduct
270+
hasProductChanges.send(())
271+
}
272+
return entityListener
273+
}
274+
218275
@MainActor
219276
func loadLastTimeRange() async -> StatsTimeRangeV4? {
220277
await withCheckedContinuation { continuation in
@@ -262,9 +319,11 @@ private extension TopPerformersDashboardViewModel {
262319
return
263320
}
264321
guard let items = topEarnerStats?.items?.sorted(by: >), items.isNotEmpty else {
322+
entityListeners = [:]
265323
return periodViewModel.update(state: .loaded(rows: []))
266324
}
267325
periodViewModel.update(state: .loaded(rows: items))
326+
observeProducts(for: items)
268327
}
269328

270329
func createResultsController(timeRange: StatsTimeRangeV4) -> ResultsController<StorageTopEarnerStats> {

0 commit comments

Comments
 (0)