Skip to content

Commit

Permalink
[Woo POS] Variation pagination (#14849)
Browse files Browse the repository at this point in the history
  • Loading branch information
jaclync authored Jan 13, 2025
2 parents 637da95 + 3b33919 commit 4fa07ca
Show file tree
Hide file tree
Showing 18 changed files with 331 additions and 93 deletions.
15 changes: 12 additions & 3 deletions Networking/Networking/Remote/ProductVariationsRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public protocol ProductVariationsRemoteProtocol {
completion: @escaping ([ProductVariation]?, Error?) -> Void)
func loadVariationsForPointOfSale(for siteID: Int64,
parentProductID: Int64,
pageNumber: Int) async throws -> [ProductVariation]
pageNumber: Int) async throws -> PagedItems<ProductVariation>
func loadProductVariation(for siteID: Int64, productID: Int64, variationID: Int64, completion: @escaping (Result<ProductVariation, Error>) -> Void)
func createProductVariation(for siteID: Int64,
productID: Int64,
Expand Down Expand Up @@ -78,15 +78,24 @@ public class ProductVariationsRemote: Remote, ProductVariationsRemoteProtocol {
/// - Returns: Variations for the provided parent product.
public func loadVariationsForPointOfSale(for siteID: Int64,
parentProductID: Int64,
pageNumber: Int = Default.pageNumber) async throws -> [ProductVariation] {
pageNumber: Int = Default.pageNumber) async throws -> PagedItems<ProductVariation> {
let request = productVariationsRequest(for: siteID,
productID: parentProductID,
variationIDs: [],
context: nil,
pageNumber: pageNumber,
pageSize: POSConstants.variationsPerPage)
let mapper = ProductVariationListMapper(siteID: siteID, productID: parentProductID)
return try await enqueue(request, mapper: mapper)

let (variations, responseHeaders) = try await enqueueWithResponseHeaders(request, mapper: mapper)

// Extracts the total number of pages from the response headers.
// Response header names are case insensitive.
let totalPages = responseHeaders?.first(where: { $0.key.lowercased() == Remote.PaginationHeaderKey.totalPagesCount.lowercased() })
.flatMap { Int($0.value) }
let hasMorePages = totalPages.map { pageNumber < $0 } ?? true

return PagedItems(items: variations, hasMorePages: hasMorePages)
}

private func productVariationsRequest(for siteID: Int64,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ final class ProductVariationsRemoteTests: XCTestCase {
network.simulateResponse(requestUrlSuffix: "products/\(sampleProductID)/variations", filename: "product-variations-load-all")

// When
let variations = try await remote.loadVariationsForPointOfSale(for: sampleSiteID, parentProductID: sampleProductID)
let variations = try await remote.loadVariationsForPointOfSale(for: sampleSiteID, parentProductID: sampleProductID).items

// Then
XCTAssertEqual(variations.count, 8)
Expand Down Expand Up @@ -224,6 +224,61 @@ final class ProductVariationsRemoteTests: XCTestCase {
XCTAssertEqual(firstVariation.menuOrder, 8)
}

func test_loadVariationsForPointOfSale_returns_page_details() async throws {
// Given
let remote = ProductVariationsRemote(network: network)
network.simulateResponse(requestUrlSuffix: "products/\(sampleProductID)/variations", filename: "product-variations-load-all")

// When
let hasMorePages = try await remote.loadVariationsForPointOfSale(for: sampleSiteID, parentProductID: sampleProductID).hasMorePages

// Then
XCTAssertTrue(hasMorePages)
}

func test_loadVariationssForPointOfSale_returns_hasMorePages_based_on_header_with_case_insensitive_name() async throws {
// Given
let remote = ProductVariationsRemote(network: network)
network.responseHeaders = ["X-WP-TotalPages": "5"]
network.simulateResponse(requestUrlSuffix: "products/\(sampleProductID)/variations", filename: "product-variations-load-all")

// When loading page 1 to 4
for pageNumber in 1...4 {
let pagedVariations = try await remote.loadVariationsForPointOfSale(for: sampleSiteID,
parentProductID: sampleProductID,
pageNumber: pageNumber)

// Then
XCTAssertTrue(pagedVariations.hasMorePages)
}

// When loading page 5
let pagedVariations = try await remote.loadVariationsForPointOfSale(for: sampleSiteID,
parentProductID: sampleProductID,
pageNumber: 5)

// Then
XCTAssertFalse(pagedVariations.hasMorePages)
}

func test_loadVariationssForPointOfSale_returns_hasMorePages_true_when_header_is_not_set() async throws {
// Given
let remote = ProductVariationsRemote(network: network)
network.responseHeaders = nil
network.simulateResponse(requestUrlSuffix: "products/\(sampleProductID)/variations", filename: "product-variations-load-all")

// When loading the first 5 pages
for pageNumber in 1...5 {
let pagedVariations = try await remote.loadVariationsForPointOfSale(for: sampleSiteID,
parentProductID: sampleProductID,
pageNumber: pageNumber)

// Then
XCTAssertTrue(pagedVariations.hasMorePages)
}
}


func test_loadVariationsForPointOfSale_properly_relays_networking_error() async throws {
// Given
let remote = ProductVariationsRemote(network: network)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@ import class Yosemite.Store

protocol PointOfSaleItemsControllerProtocol {
var itemsViewStatePublisher: any Publisher<ItemsViewState, Never> { get }
func loadInitialItems() async
func loadNextItems() async throws
func loadInitialItems(base: ItemListBaseItem) async
func loadNextItems(base: ItemListBaseItem) async throws
func reload() async
func loadInitialChildItems(for parent: POSItem) async
}

class PointOfSaleItemsController: PointOfSaleItemsControllerProtocol {
Expand All @@ -22,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 All @@ -30,7 +30,17 @@ class PointOfSaleItemsController: PointOfSaleItemsControllerProtocol {
}

@MainActor
func loadInitialItems() async {
func loadInitialItems(base: ItemListBaseItem) async {
switch base {
case .root:
await loadInitialRootItems()
case .parent(let parent):
await loadInitialChildItems(for: parent)
}
}

@MainActor
private func loadInitialRootItems() async {
itemsViewState = .init(containerState: .loading, itemsStack: ItemsStackState(root: .loading([]),
itemStates: [:]))
do {
Expand All @@ -46,7 +56,17 @@ class PointOfSaleItemsController: PointOfSaleItemsControllerProtocol {
}

@MainActor
func loadNextItems() async throws {
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 @@ -84,19 +104,59 @@ class PointOfSaleItemsController: PointOfSaleItemsControllerProtocol {
}

@MainActor
func loadInitialChildItems(for parent: POSItem) async {
private func loadInitialChildItems(for parent: POSItem) async {
updateState(for: parent, to: .loading([]))

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

@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 }
return try await fetchChildItems(for: parent, pageNumber: pageNumber)
}
} 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()))
}
}

@MainActor
private func fetchChildItems(for parent: POSItem, pageNumber: Int) async throws -> Bool {
switch parent {
case let .variableParentProduct(parentProduct):
updateState(for: parent, to: .loading([]))
do {
// TODO-14696: pagination support for variations lists
try await fetchVariationItems(parentProduct: parentProduct, parentItem: parent, pageNumber: Store.Default.firstPageNumber)
} catch {
// TODO: 14694 - Handle error from loading initial variations.
}
default:
return try await fetchVariationItems(parentProduct: parentProduct, parentItem: parent, pageNumber: pageNumber)
case .simpleProduct, .variation:
assertionFailure("Unsupported parent type for loading child items: \(parent)")
return
return false
}
}

private func paginationTracker(for parent: POSItem) -> AsyncPaginationTracker {
if let childPaginationTracker = childPaginationTrackers[parent] {
return childPaginationTracker
} else {
let newChildPaginationTracker = AsyncPaginationTracker()
childPaginationTrackers[parent] = newChildPaginationTracker
return newChildPaginationTracker
}
}
}
Expand Down Expand Up @@ -137,7 +197,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 @@ -151,6 +211,7 @@ private extension PointOfSaleItemsController {
allItems.append(contentsOf: uniqueNewItems)

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

Expand Down
7 changes: 7 additions & 0 deletions WooCommerce/Classes/POS/Models/ItemListBaseItem.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Foundation
import enum Yosemite.POSItem

enum ItemListBaseItem {
case root
case parent(POSItem)
}
18 changes: 6 additions & 12 deletions WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,9 @@ protocol PointOfSaleAggregateModelProtocol {
func trackCardPaymentsOnboardingShown()

var itemsViewState: ItemsViewState { get }
func loadInitialItems() async
func loadNextItems() async throws
func loadInitialItems(base: ItemListBaseItem) async
func loadNextItems(base: ItemListBaseItem) async throws
func reload() async
func loadInitialChildItems(for parent: POSItem) async

var cart: [CartItem] { get }
func addToCart(_ item: POSOrderableItem)
Expand Down Expand Up @@ -93,24 +92,19 @@ extension PointOfSaleAggregateModel {
}

@MainActor
func loadInitialItems() async {
await itemsController.loadInitialItems()
func loadInitialItems(base: ItemListBaseItem) async {
await itemsController.loadInitialItems(base: base)
}

@MainActor
func loadNextItems() async throws {
try await itemsController.loadNextItems()
func loadNextItems(base: ItemListBaseItem) async throws {
try await itemsController.loadNextItems(base: base)
}

@MainActor
func reload() async {
await itemsController.reload()
}

@MainActor
func loadInitialChildItems(for parent: POSItem) async {
await itemsController.loadInitialChildItems(for: parent)
}
}

// MARK: - Cart
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ struct ChildItemList: View {
guard state.items.isEmpty else {
return
}
await posModel.loadInitialChildItems(for: parentItem)
await posModel.loadInitialItems(base: .parent(parentItem))
}
}
}
Expand Down
14 changes: 4 additions & 10 deletions WooCommerce/Classes/POS/Presentation/Item Selector/ItemList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,16 @@ import struct Yosemite.POSVariableParentProduct

/// Displays a list of POS items or placeholder card based on the given state.
struct ItemList<HeaderView: View>: View {
enum BaseItem {
case root
case parent(POSItem)
}

@Environment(\.floatingControlAreaSize) private var floatingControlAreaSize: CGSize
@EnvironmentObject var posModel: PointOfSaleAggregateModel
@StateObject private var infiniteScrollTriggerDeterminer = ThresholdInfiniteScrollTriggerDeterminer()

let state: ItemListState
private let node: BaseItem
private let node: ItemListBaseItem
private let headerView: HeaderView

init(state: ItemListState,
node: BaseItem = .root,
node: ItemListBaseItem = .root,
@ViewBuilder headerView: () -> HeaderView = { EmptyView() }) {
self.state = state
self.node = node
Expand All @@ -29,11 +24,10 @@ 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()
try await posModel.loadNextItems(base: node)
},
content: {
LazyVStack {
Expand Down
2 changes: 1 addition & 1 deletion WooCommerce/Classes/POS/Presentation/ItemListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ private extension ItemListView {
#Preview("Loaded with all product types") {
let itemsController = PointOfSalePreviewItemsController()
Task { @MainActor in
await itemsController.loadInitialItems()
await itemsController.loadInitialItems(base: .root)
}
let posModel = PointOfSaleAggregateModel(
itemsController: itemsController,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ struct PointOfSaleDashboardView: View {
case .error(let errorContents):
PointOfSaleItemListErrorView(error: errorContents, onRetry: {
Task {
await posModel.loadInitialItems()
await posModel.loadInitialItems(base: .root)
}
})
case .content:
Expand Down Expand Up @@ -66,7 +66,7 @@ struct PointOfSaleDashboardView: View {
supportForm
}
.task {
await posModel.loadInitialItems()
await posModel.loadInitialItems(base: .root)
}
}

Expand Down
Loading

0 comments on commit 4fa07ca

Please sign in to comment.