Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Woo POS] [Variable Products] Use pagination headers for loading products #14728

Merged
merged 21 commits into from
Dec 19, 2024

Conversation

jaclync
Copy link
Contributor

@jaclync jaclync commented Dec 19, 2024

Closes: #14699

Description

This pull request introduces a new feature to handle response headers along with response data in network requests. It also includes changes to support paginated responses in the products loading functionality in POS. The most important changes include adding a new method to fetch response data and headers, updating the ProductsRemote class to handle paginated responses, and modifying relevant tests to accommodate these changes using a new async version of the pagination tracker.

Network response handling:

Paginated responses:

Pagination Improvements:

Tests:

Additional changes:

Steps to reproduce

  • Switch to a store eligible for POS
  • Go to Menu > Point of Sale mode --> the products should be loaded after the initial loading state
  • Scroll toward the bottom --> if the store has more than 100 simple/variable published products, the next page of products should be loaded with a placeholder row at the bottom

Testing information

  • @jaclync tests on stores with more than 1 page of products, and just 1 page of products.
  • @jaclync tests on stores with site credentials login

Screenshots

Simulator.Screen.Recording.-.iPad.Pro.11-inch.M4.-.2024-12-19.at.16.21.08.mp4

  • I have considered if this change warrants user-facing release notes and have added them to RELEASE-NOTES.txt if necessary.

Reviewer (or Author, in the case of optional code reviews):

Please make sure these conditions are met before approving the PR, or request changes if the PR needs improvement:

  • The PR is small and has a clear, single focus, or a valid explanation is provided in the description. If needed, please request to split it into smaller PRs.
  • Ensure Adequate Unit Test Coverage: The changes are reasonably covered by unit tests or an explanation is provided in the PR description.
  • Manual Testing: The author listed all the tests they ran, including smoke tests when needed (e.g., for refactorings). The reviewer confirmed that the PR works as expected on all devices (phone/tablet) and no regressions are added.

…m remote, get the total pages count from header if available, then determine if it has next page. Replace 2 internal pagination states with `PaginationTracker`.
…e async/await interface in POS. Some PointOfSaleItemsControllerTests cases are still failing.
…xpected behavior and the move of pagination tracking to PaginationTracker with fixes for issues found in the tests.
@jaclync jaclync added type: task An internally driven task. feature: POS labels Dec 19, 2024
@jaclync jaclync added this to the 21.4 milestone Dec 19, 2024
@dangermattic
Copy link
Collaborator

dangermattic commented Dec 19, 2024

1 Warning
⚠️ This PR is larger than 300 lines of changes. Please consider splitting it into smaller PRs for easier and faster reviews.

Generated by 🚫 Danger


if pageNumber != 1 && products.count == 0 {
throw PointOfSaleProductServiceError.pageOutOfRange
return ([], false)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

🗒️ for reviewer: this case should not be reached if the pagination headers are available. When the pagination headers are missing, this "page out of range" case indicates that there there are no more data to load and thus return empty items and hasNextPage: false.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks. If we want to keep this approach (rather than throwing when the header isn't available/an int) then let's add this comment in the code :)

I think it would also be reasonable to make the headers required though. All tunnelled requests will have them, and I think it's a really old header. We could check whether it's possible to have a site-credential connection without getting these headers back – first step would be to check WC3.5 as I think that's the minimum.

@wpmobilebot
Copy link
Collaborator

wpmobilebot commented Dec 19, 2024

WooCommerce iOS📲 You can test the changes from this Pull Request in WooCommerce iOS by scanning the QR code below to install the corresponding build.

App NameWooCommerce iOS WooCommerce iOS
Build Numberpr14728-1fb8df0
Version21.2
Bundle IDcom.automattic.alpha.woocommerce
Commit1fb8df0
App Center BuildWooCommerce - Prototype Builds #12228
Automatticians: You can use our internal self-serve MC tool to give yourself access to App Center if needed.

@@ -125,6 +125,7 @@ final class PointOfSaleItemsControllerTests {
let initialItems = MockPointOfSaleItemService.makeInitialItems()
itemProvider.items = initialItems
itemProvider.shouldSimulateTwoPages = true
await sut.loadInitialItems()
Copy link
Contributor Author

@jaclync jaclync Dec 19, 2024

Choose a reason for hiding this comment

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

🗒️ for reviewer: sut.loadInitialItems() was added in a few test cases before sut.loadNextItems(), because the latest page number tracking has been moved from the items controller to the pagination tracker AsyncPaginationTracker. Before this move, the page number is already 1 by default in the items controller without actually having fetched the first page. When calling loadNextItems, it increments the page number to 2 and the mock item provider appends the page 2 items to the page 1 items and returns all these items for the page 2 request:

func simulateFetchNextPage() {
items.append(contentsOf: MockPointOfSaleItemService.makeSecondPageItems())
}

This behavior doesn't seem expected, as the item provider just returns the items for a given page number without any state from previous pages. This PR removes this behavior, but please let me know if I misunderstood the expected behavior of item provider.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah that seems better

Comment on lines -232 to +245
@Test func loadNextItems_when_next_page_is_out_of_range_then_receives_error() async throws {
@Test func loadNextItems_when_next_page_is_empty_then_state_is_loaded() async throws {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

🗒️ for reviewer: similar to another comment https://github.com/woocommerce/woocommerce-ios/pull/14728/files#r1891180720, the "page out of range" error was removed because receiving empty data in > 1 page number is how we tell we've reached the last page when pagination headers are unavailable. In this scenario, I think it makes sense to transition the state to loaded.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yep, agreed. The "error" was a hack so that we could figure out when we'd gone too far and should stop requesting extra pages – it shouldn't be needed now that we have the total pages header.

Copy link
Contributor

@joshheald joshheald left a comment

Choose a reason for hiding this comment

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

Thanks for the async handler!

I haven't tested any of this yet, just some thoughts so far.

@@ -18,6 +18,7 @@ public protocol MultipartFormData {
/// Unit Testing target, and inject mocked up responses.
///
public protocol Network {
typealias ResponseHeaders = [String: String]
Copy link
Contributor

Choose a reason for hiding this comment

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

Is it worth adding a ResponseHeader struct and keeping them in an array?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right now it's a dictionary for O(1) lookup, did you mean to convert it to convert the key-value pairs to an array? Or having a struct with a dictionary field?

Copy link
Contributor

Choose a reason for hiding this comment

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

I meant something like

struct ResponseHeader {
    let name: String
    let value: String
}

public protocol Network {
    func responseDataAndHeaders(for request: URLRequestConvertible) async throws -> (Data, [ResponseHeader])
}

Not a strong requirement, just wondered whether you thought it would make the code clearer. I didn't really consider performance, but the array should be small.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Personally, I feel like [String: String] is pretty similar to the key-value pairs array both with the need to look up and optional return value, and with guaranteed performance. Maybe we can keep it simple with a dictionary for now, and update to a struct if we have more use cases of the headers.

// 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) }
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this use compactMap?

Should we throw if it's not there, rather than make it optional? I don't know how well we'll be able to handle the list if we don't have the page count...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hm good question, I was mostly afraid that some plugins or hosts (for site credentials login with direct site requests) might alter the headers and remove the pagination headers. In this case where the pagination headers are missing, I'm not sure if it's worth blocking the merchants from using POS because of the missing headers. WDYT?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think it depends...

  1. What complexity does any workaround add to the code? (If we have to keep the previous workaround in, that's a strong sign we shouldn't do it.)
  2. What kind of experience will we give them if the headers aren't present? (If it's just going to keep trying to load the next page, that's not great either, especially if those calls are rapidly repeated which is a risk here.)
  3. How likely is it that the headers aren't present?

The docs for v3 of the API say:

The total number of resources and pages are always included in the X-WP-Total and X-WP-TotalPages HTTP headers.
Which is repeated back to v1 of the API – WC 2.6 got that, and we don't support that version.

I think we can rely on them being there, but perhaps we could add some tracking now for any request that comes back without the headers, if you have concerns?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What complexity does any workaround add to the code? (If we have to keep the previous workaround in, that's a strong sign we shouldn't do it.)

Great points on the various considerations and tradeoffs. I think the current workaround is not very complicated even though not ideal - it's mostly in the remote loadProductsForPointOfSale returning true for hasMorePages by default, and PointOfSaleProductService returning false when the page items are empty (there's room for optimization there to return false for items count < page size). Having dealt with all kinds of alterations in the API response by third-party plugins, I don't know about the likelihood but there's a chance some plugin or hosting provider strips some response headers. Of course there could be none, we can add tracking to the response headers missing case and observe. If it's almost zero, let's move to throw an error when response headers are missing to simplify the code. I created an issue to track this #14737.

What kind of experience will we give them if the headers aren't present? (If it's just going to keep trying to load the next page, that's not great either, especially if those calls are rapidly repeated which is a risk here.)

There should be up to 1 extra API request per load session with the current fallback implementation (before the next reload), when we fix the infinite scroll issue. If we implement the optimization to return false for response items count < page size, then the probability of extra API request is lower (only if the last page has items size = page size).

private let itemProvider: PointOfSaleItemServiceProtocol

init(itemProvider: PointOfSaleItemServiceProtocol) {
self.itemProvider = itemProvider
self.paginationTracker = .init(pageFirstIndex: Constants.initialPage)
Copy link
Contributor

Choose a reason for hiding this comment

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

I think you can remove the constant here and just use the default initialiser

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense, updated in 27e2f91

Comment on lines +52 to +53
case .syncing:
break
Copy link
Contributor

Choose a reason for hiding this comment

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

How would we get here while it was syncing? Is it impossible?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If loadNextItems is called again while the previous call is still loading items remotely, it reaches this state.

Copy link
Contributor

Choose a reason for hiding this comment

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

With the suggestion to gate calls to loadNextItems, that shouldn't happen, but it's good to handle it in case the caller isn't doing that.

Comment on lines 104 to 107
reloadTask?.cancel()
reloadTask = Task { @MainActor in
await itemsController.reload()
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Good catch... though could/should this be inside the itemsController?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yup better not clutter the aggregate model, moved in 2d34806

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm looking a bit closer at this now that it's breaking our unit tests... could you explain again what the problem was here? Was the action cancelled when you let go of the control?

That shouldn't really happen – the refreshable modifier accepts an async function and should work with it... I'd expect it to cancel the task if the view goes off screen. Breaking the async chain by dispatching to a task seems like it causes issues – when you release the list the spinner disappears immediately becaus the reload function returns before the network response comes back

Copy link
Contributor

@joshheald joshheald Dec 19, 2024

Choose a reason for hiding this comment

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

Hmmm I think I can see what's going on now. The error we're getting is explicitlyCancelled – it looks like we're doing that by causing the view to redraw within the reload function. We call itemListStateSubject.send(.loading(allItems)) when it's called, which redraws the view to show the loading state at the top... but doing that redraw means that SwiftUI cancels the refresh control.

If we remove that line, the refresh works as you'd expect, but without the loading cell at the top. To be honest, I think that's a better trade off for the time being.

Copy link
Contributor

@joshheald joshheald Dec 19, 2024

Choose a reason for hiding this comment

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

The problem is that we don't have fine-grained state updates yet – every time we update the POSModel we redraw everything.

I think we would be permitted to change the list content without the refresh action getting cancelled, but because we don't have Observation yet our changes result in the list itself being redrawn, including the refresh control.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes the explicitlyCancelled cancelation that results in error state is the issue. Thanks for the better solution by not triggering redraw in 1fb8df0! From my testing with stores with 2 POS products in your PR #14727, I occasionally still saw this error but only briefly because the pre-existing issue #14735 where loadNextItems is also triggered when pulling to refresh a short list (this request isn't canceled and the items from this request update the items view state). Would be great to use Observation to fix this issue properly.

/// 1. Proceed only if there is next page to sync.
/// 2. Verify if the next page isn't currently being synced.
/// 3. Proceed syncing the next page.
func ensureNextPageIsSynced(syncFunction: @escaping SyncFunction) async throws -> NextPageSyncState {
Copy link
Contributor

Choose a reason for hiding this comment

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

Did you consider/try passing the syncFunction in through init?

At the call site, the repeated trailing closures feel repetitive, at least for the current example. As long as the sync functions we call are async throws, we can set our state accordingly before and after the calls from ItemListController.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I considered passing the sync function through init, but the sync involves states in self (allItems at least) so it cannot be passed in the initializer. I agree it feels repetitive passing for each method. We could consider updating this function to be static functional without any states, but that requires some refactoring and I'd prefer leaving it for later.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah yeah, of course. OK, let's keep it like this for now, I don't think it'll be realistic to make it static, because then the consumer will be really limited about what it can do with the state it stores, and testing will be a pain.

Comment on lines +91 to +93
defer {
unmarkAsBeingSynced(pageNumber: pageNumber)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I had trouble with defer working as I expected when I used it like this in the ItemsListController – are you sure it gets called if we throw an error? Should it be?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, if this defer statement is commented out, loadNextItems_after_itemProvider_throws_error_then_the_same_page_is_requested_next fails from the page not being marked as synced. unmarkAsBeingSynced was originally called after the do/catch, but when an error is thrown it's never reached. Moving it to defer gets called in both cases.

From the Apple doc:

The statements within the defer statement are executed no matter how program control is transferred. This means that a defer statement can be used, for example, to perform manual resource management such as closing file descriptors, and to perform actions that need to happen even if an error is thrown.

Copy link
Contributor

Choose a reason for hiding this comment

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

Great, thanks for checking. Goodness knows what I was doing wrong before then... perhaps I accidentally had it in the wrong scope or something.

@@ -125,6 +125,7 @@ final class PointOfSaleItemsControllerTests {
let initialItems = MockPointOfSaleItemService.makeInitialItems()
itemProvider.items = initialItems
itemProvider.shouldSimulateTwoPages = true
await sut.loadInitialItems()
Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah that seems better

Comment on lines -232 to +245
@Test func loadNextItems_when_next_page_is_out_of_range_then_receives_error() async throws {
@Test func loadNextItems_when_next_page_is_empty_then_state_is_loaded() async throws {
Copy link
Contributor

Choose a reason for hiding this comment

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

Yep, agreed. The "error" was a hack so that we could figure out when we'd gone too far and should stop requesting extra pages – it shouldn't be needed now that we have the total pages header.


if pageNumber != 1 && products.count == 0 {
throw PointOfSaleProductServiceError.pageOutOfRange
return ([], false)
Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks. If we want to keep this approach (rather than throwing when the header isn't available/an int) then let's add this comment in the code :)

I think it would also be reasonable to make the headers required though. All tunnelled requests will have them, and I think it's a really old header. We could check whether it's possible to have a site-credential connection without getting these headers back – first step would be to check WC3.5 as I think that's the minimum.

let uniqueNewItems = newItems.filter { newItem in
!allItems.contains(newItem)
}
allItems.append(contentsOf: uniqueNewItems)
return hasNextPage
return pagedItems.hasMorePages
Copy link
Contributor

@joshheald joshheald Dec 19, 2024

Choose a reason for hiding this comment

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

It's been there a while... but this function is feeling a bit strange now.

It's called fetchItems, which it does, but it doesn't return them, it returns a Bool.

If I didn't have the doc comment, I'd expect that to indicate success/failure of the fetch, not whether there are more pages. It's not a huge deal on this private function but it's niggling at me a bit.

We can leave it for now, this one will change more when the state model changes, but it'd be good to know your thoughts. I guess just returning the whole PagedItems actually would make more sense...

Copy link
Contributor Author

@jaclync jaclync Dec 19, 2024

Choose a reason for hiding this comment

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

This is the function called from the pagination tracker to sync items given a page number, which just requires a boolean return value that indicates whether there are more pages. Its usage/meaning does feel unintuitive without the doc comment.

I think it looks awkward because the items and pagination state are stored separately, but both came from one network request. I tried making the sync function stateless as in #14728 (comment) by passing the sync function try await itemProvider.providePointOfSaleItems(pageNumber: $0).hasMorePages in init, but then the controller cannot get the items easily. I'll think more about how the items and pagination states can be managed more cleanly, feel free to share any ideas as well.

Copy link
Contributor

Choose a reason for hiding this comment

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

Other than changing the function to return PagedItems<POSProduct>, which includes both the items and whether there are pages, I don't have any ideas.

Let's keep it this way; a better one might become obvious after we use it for variations.

@jaclync jaclync marked this pull request as ready for review December 19, 2024 08:26
}

@MainActor
func loadNextItems() async {
itemListStateSubject.send(.loading(allItems))
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
itemListStateSubject.send(.loading(allItems))
guard paginationTracker.hasNextPage else {
return
}
itemListStateSubject.send(.loading(allItems))

You'll also need to make hasNextPage a private(set) var as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated in a2e792a, this should fix the flickering issue when loading the next page from the infinite scroll.

Copy link
Contributor

@joshheald joshheald left a comment

Choose a reason for hiding this comment

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

With testing, it works quite well. There are some issues but they're sort of preexisting...

  1. There's a flickering of the loading next page state when we get to the bottom of the last page. This previously didn't happen, because there was a check in ItemListController before sending the loading state. It's sort of pre-existing though because it's caused by an issue with the load next items gesture, which fires ~50 times when you scroll at the bottom of the page. I've suggested a change which hides it again.
  2. Uniquing the items doesn't work at the moment – this is because of the UUIDs, and nothing to do with this PR.

Taken with any changes from my previous review, I think this is fine to merge,

@jaclync
Copy link
Contributor Author

jaclync commented Dec 19, 2024

@joshheald Thanks for the feedback! I believe I've responded to all of your comments, and the flickering issue is hopefully fixed in #14728 (comment) if you'd like to test it again.

Uniquing the items doesn't work at the moment – this is because of the UUIDs, and nothing to do with this PR.

Did this cause any visual issues in your testing?

@joshheald
Copy link
Contributor

Uniquing the items doesn't work at the moment – this is because of the UUIDs, and nothing to do with this PR.

Did this cause any visual issues in your testing?

Yes – after errors on fetching the next page, I think I was able to get duplicate items in the list. It's nothing to do with this PR though, we should fix it separately.

I'll check it again now

@jaclync
Copy link
Contributor Author

jaclync commented Dec 19, 2024

The move of the reload task in #14728 (comment) broke the reload test cases 😬 Looking into them now.

@joshheald
Copy link
Contributor

@jaclync some of the unit test failures suggest that the async code is returning before it's finished its work somewhere... there are a few failing because they're expecting a loaded state but getting a previous loading one....

@jaclync
Copy link
Contributor Author

jaclync commented Dec 19, 2024

some of the unit test failures suggest that the async code is returning before it's finished its work somewhere... there are a few failing because they're expecting a loaded state but getting a previous loading one....

Yea, because I moved the separate reload task for the refresh control fix from the aggregate model to the items controller. I will make a separate function to use the separate reload Task, and have unit tests use the core reload function.

…eal, but other places to separate the reload task are the SwiftUI view (which cannot mutate self in refreshable) or the aggregate model have their downsides as well. Having to wait for the conditions in all 5 test cases makes it look hacky.
@jaclync
Copy link
Contributor Author

jaclync commented Dec 19, 2024

The commit 4db6368 fixes the test failures by moving the core reload function to a separate function, and have unit tests use the other reload function. This isn't ideal because this function doesn't have to be public. When I have some time this/next week, I'll look into how to wait for conditions better like the XCTest waitUntil helper. I just didn't want to add the verbose wait workaround for 5 failing test cases in a haste.

…- not ideal, but other places to separate the reload task are the SwiftUI view (which cannot mutate self in refreshable) or the aggregate model have their downsides as well. Having to wait for the conditions in all 5 test cases makes it look hacky."

This reverts commit 4db6368.
Previously we sent a `.loading` state in the pull to refresh handler.

That changes the POSModel observable object, which results in the whole POS being redrawn.

Redrawing the refresh controller cancels the ongoing async task so it always shows an error.

With this change, we lose the skeleton loading cell when pulling to refresh, but the function works and the default control still shows.

With more granular state observation, we could update the list content without redrawing the list component itself, avoiding this issue.
@joshheald joshheald enabled auto-merge December 19, 2024 15:48
@joshheald joshheald merged commit a157f2b into trunk Dec 19, 2024
12 checks passed
@joshheald joshheald deleted the feat/14699-pagination-headers-products branch December 19, 2024 16:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature: POS type: task An internally driven task.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Woo POS] [Variable Products] Use pagination headers for fetching products/variations
4 participants