Skip to content

Commit

Permalink
14696 Load next variation page on child item lists
Browse files Browse the repository at this point in the history
  • Loading branch information
joshheald committed Jan 10, 2025
1 parent d67a60f commit b0098f6
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class PointOfSaleItemsController: PointOfSaleItemsControllerProtocol {
itemsStack: ItemsStackState(root: .loading([]),
itemStates: [:]))
private let paginationTracker: AsyncPaginationTracker
private var childPaginationTrackers: [POSItem: AsyncPaginationTracker] = [:]
private let itemProvider: PointOfSaleItemServiceProtocol

init(itemProvider: PointOfSaleItemServiceProtocol) {
Expand Down Expand Up @@ -56,6 +57,16 @@ class PointOfSaleItemsController: PointOfSaleItemsControllerProtocol {

@MainActor
func loadNextItems(base: ItemListBaseItem) async throws {
switch base {
case .root:
try await loadNextRootItems()
case .parent(let parent):
await loadNextChildItems(for: parent)
}
}

@MainActor
private func loadNextRootItems() async throws {
guard paginationTracker.hasNextPage else {
return
}
Expand Down Expand Up @@ -97,17 +108,59 @@ class PointOfSaleItemsController: PointOfSaleItemsControllerProtocol {
switch parent {
case let .variableParentProduct(parentProduct):
updateState(for: parent, to: .loading([]))

let paginationTracker = paginationTracker(for: parent)
do {
// TODO-14696: pagination support for variations lists
try await fetchVariationItems(parentProduct: parentProduct, parentItem: parent, pageNumber: Store.Default.firstPageNumber)
try await paginationTracker.syncFirstPage { [weak self] pageNumber in
guard let self else { return true }
return try await fetchVariationItems(parentProduct: parentProduct, parentItem: parent, pageNumber: Store.Default.firstPageNumber)
}
} catch {
// TODO: 14694 - Handle error from loading initial variations.
}

default:
assertionFailure("Unsupported parent type for loading child items: \(parent)")
return
}
}

@MainActor
private func loadNextChildItems(for parent: POSItem) async {
let paginationTracker = paginationTracker(for: parent)

guard paginationTracker.hasNextPage else {
return
}
let currentItems = itemsViewState.itemsStack.itemStates[parent]?.items ?? []
updateState(for: parent, to: .loading(currentItems))

do {
_ = try await paginationTracker.ensureNextPageIsSynced { [weak self] pageNumber in
guard let self else { return true }
switch parent {
case let .variableParentProduct(parentProduct):
return try await fetchVariationItems(parentProduct: parentProduct, parentItem: parent, pageNumber: pageNumber)
default:
assertionFailure()
return true
}
}
} catch {
// TODO: 14694 - Handle error from loading the next page, like showing an error UI at the end or as an overlay.
updateState(for: parent, to: .error(PointOfSaleErrorState.errorOnLoadingProducts()))
}
}

private func paginationTracker(for parent: POSItem) -> AsyncPaginationTracker {
if let childPaginationTracker = childPaginationTrackers[parent] {
return childPaginationTracker
} else {
let newChildPaginationTracker = AsyncPaginationTracker()
childPaginationTrackers[parent] = newChildPaginationTracker
return newChildPaginationTracker
}
}
}

private extension PointOfSaleItemsController {
Expand Down Expand Up @@ -146,7 +199,7 @@ private extension PointOfSaleItemsController {
private func fetchVariationItems(parentProduct: POSVariableParentProduct,
parentItem: POSItem,
pageNumber: Int,
appendToExistingItems: Bool = true) async throws {
appendToExistingItems: Bool = true) async throws -> Bool {
let pagedItems = try await itemProvider.providePointOfSaleVariationItems(
for: parentProduct,
pageNumber: pageNumber
Expand All @@ -160,6 +213,7 @@ private extension PointOfSaleItemsController {
allItems.append(contentsOf: uniqueNewItems)

updateState(for: parentItem, to: .loaded(allItems, hasMoreItems: pagedItems.hasMorePages))
return pagedItems.hasMorePages
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ struct ItemList<HeaderView: View>: View {
InfiniteScrollView(
triggerDeterminer: infiniteScrollTriggerDeterminer,
loadMore: {
guard case .root = node,
case .loaded(_, let hasMoreItems) = state,
guard case .loaded(_, let hasMoreItems) = state,
hasMoreItems
else { return }
try await posModel.loadNextItems(base: node)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import Testing
import Foundation
import Combine
@testable import WooCommerce
import struct Yosemite.POSVariableParentProduct
import enum Yosemite.POSItem

final class PointOfSaleItemsControllerTests {
private let itemProvider: MockPointOfSaleItemService
Expand Down Expand Up @@ -193,6 +196,32 @@ final class PointOfSaleItemsControllerTests {
#expect(items.count == 4)
}

@Test func loadNextItems_child_when_simulateFetchNextPage_then_state_is_loaded_with_hasMoreItems() async throws {
// Given
let parentItem = POSItem.variableParentProduct(POSVariableParentProduct(id: UUID(),
name: "Fake Parent",
productImageSource: nil,
productID: 12345))
let baseItem = ItemListBaseItem.parent(parentItem)
itemProvider.items = [parentItem]
itemProvider.shouldSimulateTwoPagesOfVariations = true
itemProvider.shouldSimulateMorePagesOfVariations = true

await sut.loadInitialItems(base: .root)
await sut.loadInitialItems(base: baseItem)

// When
try await sut.loadNextItems(base: baseItem)

// Then
guard case .loaded(let items, let hasMoreItems) = itemsViewState.itemsStack.itemStates[parentItem] else {
Issue.record("Expected loaded ItemList state, but got \(itemsViewState)")
return
}
#expect(hasMoreItems)
#expect(items.count == 4)
}

@Test func loadInitialItems_when_no_items_then_state_is_loaded_empty() async throws {
// Given
itemProvider.shouldReturnZeroItems = true
Expand Down
50 changes: 49 additions & 1 deletion WooCommerce/WooCommerceTests/POS/Mocks/MockPOSItemProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import protocol Yosemite.PointOfSaleItemServiceProtocol
import enum Yosemite.POSItem
import protocol Yosemite.POSOrderableItem
@testable import struct Yosemite.POSSimpleProduct
@testable import struct Yosemite.POSVariation
import struct Yosemite.PagedItems
import struct Yosemite.POSVariableParentProduct

Expand All @@ -29,8 +30,15 @@ final class MockPointOfSaleItemService: PointOfSaleItemServiceProtocol {
return .init(items: MockPointOfSaleItemService.makeInitialItems(), hasMorePages: shouldSimulateTwoPages)
}

var shouldSimulateTwoPagesOfVariations = false
var shouldSimulateMorePagesOfVariations = false
func providePointOfSaleVariationItems(for parentProduct: POSVariableParentProduct, pageNumber: Int) async throws -> PagedItems<POSItem> {
.init(items: [], hasMorePages: false)
if shouldSimulateTwoPagesOfVariations,
pageNumber > 1 {
return .init(items: MockPointOfSaleItemService.makeSecondPageVariationItems(), hasMorePages: shouldSimulateMorePagesOfVariations)
}

return .init(items: MockPointOfSaleItemService.makeInitialVariationItems(), hasMorePages: shouldSimulateTwoPagesOfVariations)
}
}

Expand Down Expand Up @@ -71,6 +79,46 @@ extension MockPointOfSaleItemService {
return [.simpleProduct(product3), .simpleProduct(product4)]
}

static func makeInitialVariationItems() -> [POSItem] {
let fakeUUID1 = UUID(uuidString: "B04AF636-CF6C-11EF-A45C-FA719FB6C0F0") ?? UUID()
let fakeUUID2 = UUID(uuidString: "B04AF727-CF6C-11EF-A45C-FA719FB6C0F0") ?? UUID()

let variation1 = POSVariation(id: fakeUUID1,
name: "Choco",
formattedPrice: "$2.00",
price: "2.00",
productID: 1,
variationID: 1)

let variation2 = POSVariation(id: fakeUUID2,
name: "Vanilla",
formattedPrice: "$2.00",
price: "2.00",
productID: 1,
variationID: 2)
return [.variation(variation1), .variation(variation2)]
}

static func makeSecondPageVariationItems() -> [POSItem] {
let fakeUUID3 = UUID(uuidString: "B04AF758-CF6C-11EF-A45C-FA719FB6C0F0") ?? UUID()
let fakeUUID4 = UUID(uuidString: "B04AF78A-CF6C-11EF-A45C-FA719FB6C0F0") ?? UUID()

let variation3 = POSVariation(id: fakeUUID3,
name: "Strawberry",
formattedPrice: "$2.00",
price: "2.00",
productID: 1,
variationID: 3)

let variation4 = POSVariation(id: fakeUUID4,
name: "Pistachio",
formattedPrice: "$3.00",
price: "2.00",
productID: 1,
variationID: 4)
return [.variation(variation3), .variation(variation4)]
}

enum MockError: Error {
case requestFailed
}
Expand Down
2 changes: 1 addition & 1 deletion Yosemite/Yosemite/PointOfSale/PointOfSaleItemService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ public final class PointOfSaleItemService: PointOfSaleItemServiceProtocol {
variationID: variation.productVariationID))
}),
// TODO-14696: pagination support for variations lists
hasMorePages: false
hasMorePages: pagedVariations.hasMorePages
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ final class PointOfSaleItemServiceTests: XCTestCase {
}
}

func test_PointOfSaleItemServiceProtocol_when_empty_data_for_non_first_page_then_returns_empty_items_and_no_next_page() async throws {
func test_PointOfSaleItemServiceProtocol_when_empty_data_for_non_first_page_of_products_then_returns_empty_items_and_no_next_page() async throws {
// Given
network.simulateResponse(requestUrlSuffix: "products", filename: "empty-data-array")

Expand Down Expand Up @@ -215,6 +215,42 @@ final class PointOfSaleItemServiceTests: XCTestCase {
XCTAssertEqual(firstVariation.productVariationID, 1275)
}

func test_providePointOfSaleVariationItems_returns_variation_page_details_when_load_succeeds() async throws {
// Given
let itemProvider = PointOfSaleItemService(siteID: siteID,
currencySettings: currencySettings,
network: network,
isVariableProductsFeatureEnabled: true)
let parentProductID: Int64 = 123

// When
network.responseHeaders = ["X-WP-TotalPages": "5"]
network.simulateResponse(requestUrlSuffix: "products/\(parentProductID)/variations", filename: "product-variations-load-all")
let pagedVariations = try await itemProvider.providePointOfSaleVariationItems(
for: .init(
id: .init(),
name: "Tea",
productImageSource: nil,
productID: parentProductID,
allAttributes: [
.init(
siteID: siteID,
attributeID: 0,
name: "Size",
position: 4,
visible: true,
variation: true,
options: ["6 piece"]
)
]
),
pageNumber: 1
)

// Then
XCTAssertTrue(pagedVariations.hasMorePages)
}

func test_providePointOfSaleVariationItems_throws_error_when_variations_load_fails() async throws {
// Given
let itemProvider = PointOfSaleItemService(siteID: siteID,
Expand Down

0 comments on commit b0098f6

Please sign in to comment.