Skip to content

Commit 70743e2

Browse files
authored
Merge pull request #8284 from woocommerce/issue/8199-fetch-top-performers-data
Analytics Hub: Fetch & Populate data for Products Card - Items Sold
2 parents 72e3aa6 + 6e97387 commit 70743e2

File tree

7 files changed

+125
-28
lines changed

7 files changed

+125
-28
lines changed

Networking/Networking/Model/Copiable/Models+Copiable.generated.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1987,6 +1987,30 @@ extension Networking.SystemPlugin {
19871987
}
19881988
}
19891989

1990+
extension Networking.TopEarnerStats {
1991+
public func copy(
1992+
siteID: CopiableProp<Int64> = .copy,
1993+
date: CopiableProp<String> = .copy,
1994+
granularity: CopiableProp<StatGranularity> = .copy,
1995+
limit: CopiableProp<String> = .copy,
1996+
items: NullableCopiableProp<[TopEarnerStatsItem]> = .copy
1997+
) -> Networking.TopEarnerStats {
1998+
let siteID = siteID ?? self.siteID
1999+
let date = date ?? self.date
2000+
let granularity = granularity ?? self.granularity
2001+
let limit = limit ?? self.limit
2002+
let items = items ?? self.items
2003+
2004+
return Networking.TopEarnerStats(
2005+
siteID: siteID,
2006+
date: date,
2007+
granularity: granularity,
2008+
limit: limit,
2009+
items: items
2010+
)
2011+
}
2012+
}
2013+
19902014
extension Networking.TopEarnerStatsItem {
19912015
public func copy(
19922016
productID: CopiableProp<Int64> = .copy,

Networking/Networking/Model/Stats/TopEarnerStats.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Codegen
33

44
/// Represents Top Earner (aka top performer) stats over a specific period.
55
///
6-
public struct TopEarnerStats: Decodable, GeneratedFakeable {
6+
public struct TopEarnerStats: Decodable, GeneratedFakeable, GeneratedCopiable {
77
public let siteID: Int64
88
public let date: String
99
public let granularity: StatGranularity

WooCommerce/Classes/Model/TopEarnerStatsItem+Woo.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,10 @@ extension TopEarnerStatsItem {
1212
var formattedTotalString: String {
1313
return CurrencyFormatter(currencySettings: ServiceLocator.currencySettings).formatHumanReadableAmount(String(total), with: currency) ?? String()
1414
}
15+
16+
/// Returns the total string including the currency symbol.
17+
///
18+
var totalString: String {
19+
CurrencyFormatter(currencySettings: ServiceLocator.currencySettings).formatAmount(Decimal(total), with: currency) ?? ""
20+
}
1521
}

WooCommerce/Classes/ViewRelated/Dashboard/Analytics Hub/AnalyticsHubViewModel.swift

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ final class AnalyticsHubViewModel: ObservableObject {
3737

3838
/// Products Card ViewModel
3939
///
40-
@Published var productCard = AnalyticsHubViewModel.productCard(currentPeriodStats: nil, previousPeriodStats: nil)
40+
@Published var productCard = AnalyticsHubViewModel.productCard(currentPeriodStats: nil, previousPeriodStats: nil, itemsSoldStats: nil)
4141

4242
/// Time Range Selection Type
4343
///
@@ -57,6 +57,10 @@ final class AnalyticsHubViewModel: ObservableObject {
5757
///
5858
@Published private var previousOrderStats: OrderStatsV4? = nil
5959

60+
/// Stats for the current top items sold. Used in the products card.
61+
///
62+
@Published private var itemsSoldStats: TopEarnerStats? = nil
63+
6064
/// Time Range selection data defining the current and previous time period
6165
///
6266
private var timeRangeSelection: AnalyticsHubTimeRangeSelection
@@ -89,9 +93,15 @@ private extension AnalyticsHubViewModel {
8993
async let previousPeriodRequest = retrieveStats(earliestDateToInclude: previousTimeRange.start,
9094
latestDateToInclude: previousTimeRange.end,
9195
forceRefresh: true)
92-
let (currentPeriodStats, previousPeriodStats) = try await (currentPeriodRequest, previousPeriodRequest)
96+
97+
async let itemsSoldRequest = retrieveTopItemsSoldStats(earliestDateToInclude: currentTimeRange.start,
98+
latestDateToInclude: currentTimeRange.end,
99+
forceRefresh: true)
100+
101+
let (currentPeriodStats, previousPeriodStats, itemsSoldStats) = try await (currentPeriodRequest, previousPeriodRequest, itemsSoldRequest)
93102
self.currentOrderStats = currentPeriodStats
94103
self.previousOrderStats = previousPeriodStats
104+
self.itemsSoldStats = itemsSoldStats
95105
}
96106

97107
@MainActor
@@ -114,6 +124,25 @@ private extension AnalyticsHubViewModel {
114124
stores.dispatch(action)
115125
}
116126
}
127+
128+
@MainActor
129+
/// Retrieves top ItemsSold stats using the `retrieveTopEarnerStats` action but without saving results into storage.
130+
///
131+
func retrieveTopItemsSoldStats(earliestDateToInclude: Date, latestDateToInclude: Date, forceRefresh: Bool) async throws -> TopEarnerStats {
132+
try await withCheckedThrowingContinuation { continuation in
133+
let action = StatsActionV4.retrieveTopEarnerStats(siteID: siteID,
134+
timeRange: .thisYear, // Only needed for storing purposes, we can ignore it.
135+
earliestDateToInclude: earliestDateToInclude,
136+
latestDateToInclude: latestDateToInclude,
137+
quantity: Constants.maxNumberOfTopItemsSold,
138+
forceRefresh: forceRefresh,
139+
saveInStorage: false,
140+
onCompletion: { result in
141+
continuation.resume(with: result)
142+
})
143+
stores.dispatch(action)
144+
}
145+
}
117146
}
118147

119148
// MARK: Data - UI mapping
@@ -128,16 +157,19 @@ private extension AnalyticsHubViewModel {
128157
func switchToErrorState() {
129158
self.currentOrderStats = nil
130159
self.previousOrderStats = nil
160+
self.itemsSoldStats = nil
131161
}
132162

133163
func bindViewModelsWithData() {
134-
Publishers.CombineLatest($currentOrderStats, $previousOrderStats)
135-
.sink { [weak self] currentOrderStats, previousOrderStats in
164+
Publishers.CombineLatest3($currentOrderStats, $previousOrderStats, $itemsSoldStats)
165+
.sink { [weak self] currentOrderStats, previousOrderStats, itemsSoldStats in
136166
guard let self else { return }
137167

138168
self.revenueCard = AnalyticsHubViewModel.revenueCard(currentPeriodStats: currentOrderStats, previousPeriodStats: previousOrderStats)
139169
self.ordersCard = AnalyticsHubViewModel.ordersCard(currentPeriodStats: currentOrderStats, previousPeriodStats: previousOrderStats)
140-
self.productCard = AnalyticsHubViewModel.productCard(currentPeriodStats: currentOrderStats, previousPeriodStats: previousOrderStats)
170+
self.productCard = AnalyticsHubViewModel.productCard(currentPeriodStats: currentOrderStats,
171+
previousPeriodStats: previousOrderStats,
172+
itemsSoldStats: itemsSoldStats)
141173

142174
}.store(in: &subscriptions)
143175

@@ -197,25 +229,38 @@ private extension AnalyticsHubViewModel {
197229
syncErrorMessage: Localization.OrderCard.noOrders)
198230
}
199231

200-
static func productCard(currentPeriodStats: OrderStatsV4?, previousPeriodStats: OrderStatsV4?) -> AnalyticsProductCardViewModel {
232+
/// Helper function to create a `AnalyticsProductCardViewModel` from the fetched stats.
233+
///
234+
static func productCard(currentPeriodStats: OrderStatsV4?,
235+
previousPeriodStats: OrderStatsV4?,
236+
itemsSoldStats: TopEarnerStats?) -> AnalyticsProductCardViewModel {
201237
let showSyncError = currentPeriodStats == nil || previousPeriodStats == nil
202238
let itemsSold = StatsDataTextFormatter.createItemsSoldText(orderStats: currentPeriodStats)
203239
let itemsSoldDelta = StatsDataTextFormatter.createOrderItemsSoldDelta(from: previousPeriodStats, to: currentPeriodStats)
204240

205-
let imageURL = URL(string: "https://s0.wordpress.com/i/store/mobile/plans-premium.png")
206241
return AnalyticsProductCardViewModel(itemsSold: itemsSold,
207242
delta: itemsSoldDelta.string,
208243
deltaBackgroundColor: Constants.deltaColor(for: itemsSoldDelta.direction),
209-
itemsSoldData: [ // Temporary data
210-
.init(imageURL: imageURL, name: "Tabletop Photos", details: "Net Sales: $1,232", value: "32"),
211-
.init(imageURL: imageURL, name: "Kentya Palm", details: "Net Sales: $800", value: "10"),
212-
.init(imageURL: imageURL, name: "Love Ficus", details: "Net Sales: $599", value: "5"),
213-
.init(imageURL: imageURL, name: "Bird Of Paradise", details: "Net Sales: $23.50", value: "2")
214-
],
244+
itemsSoldData: itemSoldRows(from: itemsSoldStats),
215245
isRedacted: false,
216246
showSyncError: showSyncError)
217247
}
218248

249+
/// Helper functions to create `TopPerformersRow.Data` items rom the provided `TopEarnerStats`.
250+
///
251+
static func itemSoldRows(from itemSoldStats: TopEarnerStats?) -> [TopPerformersRow.Data] {
252+
guard let items = itemSoldStats?.items else {
253+
return []
254+
}
255+
256+
return items.map { item in
257+
TopPerformersRow.Data(imageURL: URL(string: item.imageUrl ?? ""),
258+
name: item.productName ?? "",
259+
details: Localization.ProductCard.netSales(value: item.totalString),
260+
value: "\(item.quantity)")
261+
}
262+
}
263+
219264
static func timeRangeCard(timeRangeSelection: AnalyticsHubTimeRangeSelection) -> AnalyticsTimeRangeCardViewModel {
220265
return AnalyticsTimeRangeCardViewModel(selectedRangeTitle: timeRangeSelection.rangeSelectionDescription,
221266
currentRangeSubtitle: timeRangeSelection.currentRangeDescription,
@@ -226,6 +271,8 @@ private extension AnalyticsHubViewModel {
226271
// MARK: - Constants
227272
private extension AnalyticsHubViewModel {
228273
enum Constants {
274+
static let maxNumberOfTopItemsSold = 5
275+
229276
static func deltaColor(for direction: StatsDataTextFormatter.DeltaPercentage.Direction) -> UIColor {
230277
switch direction {
231278
case .positive:
@@ -252,5 +299,12 @@ private extension AnalyticsHubViewModel {
252299
static let noOrders = NSLocalizedString("Unable to load order analytics",
253300
comment: "Text displayed when there is an error loading order stats data.")
254301
}
302+
303+
enum ProductCard {
304+
static func netSales(value: String) -> String {
305+
String.localizedStringWithFormat(NSLocalizedString("Net sales: %@", comment: "Label for the total sales of a product in the Analytics Hub"),
306+
value)
307+
}
308+
}
255309
}
256310
}

WooCommerce/Classes/ViewRelated/Dashboard/Analytics Hub/AnalyticsProductCard.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ struct AnalyticsProductCard: View {
6060

6161
TopPerformersView(itemTitle: Localization.title.localizedCapitalized, valueTitle: Localization.itemsSold, rows: itemsSoldData)
6262
.padding(.top, Layout.columnSpacing)
63-
63+
.redacted(reason: isRedacted ? .placeholder : [])
64+
.shimmering(active: isRedacted)
6465
}
6566
.padding(Layout.cardPadding)
6667
}

WooCommerce/Classes/ViewRelated/Dashboard/MyStore/Cells/ProductTableViewCell.swift

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ extension ProductTableViewCell.ViewModel {
114114
detailText = String.localizedStringWithFormat(
115115
NSLocalizedString("Net sales: %@",
116116
comment: "Top performers — label for the total sales of a product"),
117-
statsItem?.totalString(currencyFormatter: currencyFormatter) ?? ""
117+
statsItem?.totalString ?? ""
118118
)
119119
accessoryText = "\(statsItem?.quantity ?? 0)"
120120
backgroundColor = ProductTableViewCell.Constants.backgroundColor
@@ -135,10 +135,3 @@ private extension ProductTableViewCell {
135135
static let backgroundColor: UIColor = .systemBackground
136136
}
137137
}
138-
139-
private extension TopEarnerStatsItem {
140-
/// Returns a total string without rounding up including the currency symbol.
141-
func totalString(currencyFormatter: CurrencyFormatter) -> String? {
142-
return currencyFormatter.formatAmount(Decimal(total), with: currency)
143-
}
144-
}

WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Analytics Hub/AnalyticsHubViewModelTests.swift

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,17 @@ final class AnalyticsHubViewModelTests: XCTestCase {
1313
func test_cards_viewmodels_show_correct_data_after_updating_from_network() async {
1414
// Given
1515
let vm = AnalyticsHubViewModel(siteID: 123, statsTimeRange: .thisMonth, stores: stores)
16-
let stats = OrderStatsV4.fake().copy(totals: .fake().copy(totalOrders: 15, totalItemsSold: 5, grossRevenue: 62))
16+
1717
stores.whenReceivingAction(ofType: StatsActionV4.self) { action in
18-
if case let .retrieveCustomStats(_, _, _, _, _, _, completion) = action {
18+
switch action {
19+
case let .retrieveCustomStats(_, _, _, _, _, _, completion):
20+
let stats = OrderStatsV4.fake().copy(totals: .fake().copy(totalOrders: 15, totalItemsSold: 5, grossRevenue: 62))
1921
completion(.success(stats))
22+
case let .retrieveTopEarnerStats(_, _, _, _, _, _, _, completion):
23+
let topEarners = TopEarnerStats.fake().copy(items: [.fake()])
24+
completion(.success(topEarners))
25+
default:
26+
break
2027
}
2128
}
2229

@@ -31,14 +38,20 @@ final class AnalyticsHubViewModelTests: XCTestCase {
3138
XCTAssertEqual(vm.revenueCard.leadingValue, "$62")
3239
XCTAssertEqual(vm.ordersCard.leadingValue, "15")
3340
XCTAssertEqual(vm.productCard.itemsSold, "5")
41+
XCTAssertEqual(vm.productCard.itemsSoldData.count, 1)
3442
}
3543

3644
func test_cards_viewmodels_show_sync_error_after_getting_error_from_network() async {
3745
// Given
3846
let vm = AnalyticsHubViewModel(siteID: 123, statsTimeRange: .thisMonth, stores: stores)
3947
stores.whenReceivingAction(ofType: StatsActionV4.self) { action in
40-
if case let .retrieveCustomStats(_, _, _, _, _, _, completion) = action {
48+
switch action {
49+
case let .retrieveCustomStats(_, _, _, _, _, _, completion):
50+
completion(.failure(NSError(domain: "Test", code: 1)))
51+
case let .retrieveTopEarnerStats(_, _, _, _, _, _, _, completion):
4152
completion(.failure(NSError(domain: "Test", code: 1)))
53+
default:
54+
break
4255
}
4356
}
4457

@@ -54,16 +67,22 @@ final class AnalyticsHubViewModelTests: XCTestCase {
5467
func test_cards_viewmodels_redacted_while_updating_from_network() async {
5568
// Given
5669
let vm = AnalyticsHubViewModel(siteID: 123, statsTimeRange: .thisMonth, stores: stores)
57-
let stats = OrderStatsV4.fake().copy(totals: .fake().copy(totalOrders: 15, totalItemsSold: 5, grossRevenue: 62))
5870
var loadingRevenueCard: AnalyticsReportCardViewModel?
5971
var loadingOrdersCard: AnalyticsReportCardViewModel?
6072
var loadingProductsCard: AnalyticsProductCardViewModel?
6173
stores.whenReceivingAction(ofType: StatsActionV4.self) { action in
62-
if case let .retrieveCustomStats(_, _, _, _, _, _, completion) = action {
74+
switch action {
75+
case let .retrieveCustomStats(_, _, _, _, _, _, completion):
76+
let stats = OrderStatsV4.fake().copy(totals: .fake().copy(totalOrders: 15, totalItemsSold: 5, grossRevenue: 62))
6377
loadingRevenueCard = vm.revenueCard
6478
loadingOrdersCard = vm.ordersCard
6579
loadingProductsCard = vm.productCard
6680
completion(.success(stats))
81+
case let .retrieveTopEarnerStats(_, _, _, _, _, _, _, completion):
82+
let topEarners = TopEarnerStats.fake().copy(items: [.fake()])
83+
completion(.success(topEarners))
84+
default:
85+
break
6786
}
6887
}
6988

0 commit comments

Comments
 (0)