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

Update quick order list to use paginated product.variants #3710

Merged
merged 1 commit into from
Feb 24, 2025

Conversation

lhoffbeck
Copy link
Contributor

@lhoffbeck lhoffbeck commented Jan 28, 2025

PR Summary:

Updates quick order list component to paginate variants

Why are these changes introduced?

What this IS
Product.variants is capped to return a max of 250 objects, see docs for more details. As a result, the quick order list as-is can't contain a comprehensive view of all of a product's variants.

The main changes in this PR are to (1) paginate product.variants when rendering quick order list and (2) display pagination links within quick order list. There are a number of other minor changes to refactor and simplify the component.

What this IS NOT
While we did fix a number of existing bugs, this is NOT intended to be a full rebuild of the quick order list.

What approach did you take?

Code overview / brief demo: https://share.descript.com/view/DwFdTGzu2kM

Screenshot 2025-01-31 at 9 20 52 AM

Testing steps/scenarios

Currently only on spin while waiting for liquid pagination changes to land in production. If my spin link dies you can use this link to resume the instance. Note that requests are much slower than they are in prod.

Demo links

Checklist

@lhoffbeck lhoffbeck force-pushed the update-quick-order-list-to-use-paginated-variants branch from 92127ee to 5bff9dc Compare January 28, 2025 15:23
Comment on lines +1226 to +1240
setRequestStarted(requestStarted) {
this._requestStarted = requestStarted;
}

get requestStarted() {
return this._requestStarted;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Migrated class variable requestStarted to setRequestStarted/getRequestStarted functions and updated usage. This is the parent component, children were directly setting this.requestStarted. Less error prone / easier to determine what's happening by converting to funcs on the parent.

Comment on lines -1220 to -1228
const quickBulkElement = this.closest('quick-order-list') || this.closest('quick-add-bulk');
quickBulkElement.updateMultipleQty(items);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was doing an unnecessary DOM call. BulkAdd is already subclassed by QuickOrderList and QuickAddBulk, so we can just call updateMultipleQty on the instance.

@@ -1247,18 +1257,11 @@ class BulkAdd extends HTMLElement {
} else {
event.target.setCustomValidity('');
event.target.reportValidity();
event.target.setAttribute('value', inputValue);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This enables us to treat the DOM as a source of truth rather than maintaining client state for all pending requests. See QuickOrderList.renderSections for how it's used.

Comment on lines -1254 to -1267
getSectionsUrl() {
if (window.pageNumber) {
return `${window.location.pathname}?page=${window.pageNumber}`;
} else {
return `${window.location.pathname}`;
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was only used by the QuickOrderBulk subclass, no need for it to be on the parent.

@@ -26,7 +26,7 @@ quantity-popover volume-pricing li {
.quantity-popover__info .button-close,
.variant-remove-total quick-order-list-remove-all-button .button,
.quick-order-list-total__confirmation quick-order-list-remove-all-button .button,
quantity-popover quick-order-list-remove-button .button {
quantity-popover .quick-order-list-remove-button .button {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

QuickOrderListRemoveButton was modeled as a web component that also subclassed BulkAdd. As a result, each QuickOrderListRemoveButton instance had its own quantity update queue and you could end up in funky UI states 😬

By migrating it to a simple button and adding an event listener to QuickOrderList, we're able to correctly queue events.

Comment on lines 54 to 66
listenForActiveInput() {
if (!this.classList.contains('hidden')) {
this.getInput().addEventListener('focusin', (event) => event.target.select());
this.getInput()?.addEventListener('focusin', (event) => event.target.select());
}
this.isEnterPressed = false;
}

listenForKeydown() {
this.getInput().addEventListener('keydown', (event) => {
this.getInput()?.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
this.getInput().blur();
this.getInput()?.blur();
this.isEnterPressed = true;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

🐛-fix not related to this PR. This was throwing an error if quick add bulk was in a sold out state and no input was shown.

Comment on lines +145 to +147
id: `quick-add-bulk-${this.dataset.index}-${this.closest('.collection-quick-add-bulk').dataset.id}`,
section: this.closest('.collection-quick-add-bulk').dataset.id,
selector: `#quick-add-bulk-${this.dataset.id}-${this.closest('.collection-quick-add-bulk').dataset.id}`,
selector: `#quick-add-bulk-${this.dataset.index}-${this.closest('.collection-quick-add-bulk').dataset.id}`,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

🐛-fix not related to this PR. QuickAddBulk doesn't have a data-id, this section was always getting ignored.

@@ -148,7 +153,7 @@ if (!customElements.get('quick-add-bulk')) {
},
{
id: 'CartDrawer',
selector: '#CartDrawer',
selector: '.drawer__inner',
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Minor refactor -- by targeting .drawer__inner rather than #CartDrawer we don't need to do this cart rebind because the content isn't overwritten.

Comment on lines -171 to -173
setTimeout(() => {
document.querySelector('#CartDrawer-Overlay').addEventListener('click', this.cart.close.bind(this.cart));
});
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is pretty bad, we shouldn't declaratively rebind events in another component. We were able to remove it here by updating the targeting (above).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As a side note, I really dislike calling cart with a sections prop to fetch updates for sections outside the current component and then manually updating them in the response. Makes sense why it was done from an efficiency perspective, but the pattern I'd ideally like to see here is to publish an event that the cart got updated and any components that depend on cart refetch themselves.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changes in this file were primarily to adjust table paddings to work with pagination, and migrate away from the quick-order-list-remove-button component to a simple button (see context).

@@ -334,6 +334,10 @@ quick-order-list-remove-button:hover .icon-remove {
visibility: hidden;
}

.quick-order-list-total__info .loading__spinner:not(.hidden) + quick-order-list-remove-all-button {
visibility: hidden;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This fixes a bug where the spinner next to the view cart button in the table footer would get positioned ~20px below center.

Comment on lines -1 to -14
if (!customElements.get('quick-order-list-remove-button')) {
customElements.define(
'quick-order-list-remove-button',
class QuickOrderListRemoveButton extends BulkAdd {
constructor() {
super();
this.addEventListener('click', (event) => {
event.preventDefault();
this.startQueue(this.dataset.index, 0);
});
}
}
);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Replaced this with a simple button so that events would be processed in the same QuickOrderList queue as the other quantity updates.

if (!customElements.get('quick-order-list-remove-all-button')) {
customElements.define(
'quick-order-list-remove-all-button',
class QuickOrderListRemoveAllButton extends HTMLElement {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This isn't actually removed, just moved after QuickOrderList to fix initialization order

}
);
}

if (!customElements.get('quick-order-list')) {
customElements.define(
'quick-order-list',
class QuickOrderList extends BulkAdd {
Copy link
Contributor Author

@lhoffbeck lhoffbeck Jan 31, 2025

Choose a reason for hiding this comment

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

The biggest change in this class was to handle paginated variants. Primary changes are:

  • Capturing pagination link clicks and updating content using the section rendering API
  • Removed assumptions that all variants were present in the quick order list table

There's also some non-trivial refactoring. We were able to simplify a decent amount of code + remove code that wasn't used (example), didn't work (example) / work correctly (example).

}

get cartVariantsForProduct() {
return JSON.parse(this.querySelector('[data-cart-contents]')?.innerHTML || '[]');
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the secret sauce for remove all. Where we used to read variant inputs with non-zero quantities out of the DOM (which doesn't work with paginated variants), we now grab the JSON dump of this cart product (liquid change).

event.target.removeEventListener('keydown', handleKeydown);
};

event.target.addEventListener('keydown', handleKeydown);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Bugfix - this event was getting rebound to an input every time it changed 😬

});
});
}

getSectionsToRender() {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sections / targets got updated to simplify cart update the same as in quick-add-bulk and remove the duplicate section for quick order list.

});
}

renderSections(parsedState) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Primary change here is to how the quickorderlist section is updated to account for pagination. Fixed a few bugs too:

  • focus state is now preserved after update
  • no longer ignores cart updates to this product made from other tabs

if (this.dataset.action === this.actions.confirm) {
this.toggleConfirmation(false, true);
} else if (this.dataset.action === this.actions.remove) {
const items = this.quickOrderList.cartVariantsForProduct.reduce(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This class was moved down from the start of the file. Only other real change is that we grab the total cart contents from this.quickOrderList.cartVariantsForProduct rather than reading from the DOM.

{{ 'icon-error.svg' | inline_asset_content }}
<span class="svg-wrapper">
{{ 'icon-error.svg' | inline_asset_content }}
</span>
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixes bug where error message would have a gigantic icon

Copy link
Contributor

@Oliviammarcello Oliviammarcello left a comment

Choose a reason for hiding this comment

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

UX works as expected!

Copy link
Contributor

@ludoboludo ludoboludo left a comment

Choose a reason for hiding this comment

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

A few things I noticed while testing though they might have been existing bug/issues 🤔

  • the loading spinner when changing page seem odd to me as it's next to view cart though we're not updating anything about the cart (screenshot)
  • when I go to remove all, it seem to lose my focus position and sends me back to the top of the product list - recording
  • some odd scroll behaviour both on the pdp and modal - recording

@lhoffbeck
Copy link
Contributor Author

Thanks @ludoboludo !

the loading spinner when changing page seem odd to me as it's next to view cart though we're not updating anything about the cart (screenshot)

I don't have major feelings about this one way or another. I like the spinner replacing the remove all button when the page is changing to help prevent foot-guns, but we could also handle that by just disabling/enabling that button with the rest of the table. Other thing to call out is that the cart quantity could be updated when changing pages if any quantities changed in another tab.

I'll dig into the other issues, thanks for the videos they helped a ton 🙏

@lhoffbeck lhoffbeck force-pushed the update-quick-order-list-to-use-paginated-variants branch from d0ee95a to 9a7b42d Compare February 3, 2025 15:23
@Oliviammarcello
Copy link
Contributor

-the loading spinner when changing page seem odd to me as it's next to view cart though we're not updating anything about the cart (screenshot)

I don't have major feelings about this one way or another. I like the spinner replacing the remove all button when the page is changing to help prevent foot-guns, but we could also handle that by just disabling/enabling that button with the rest of the table. Other thing to call out is that the cart quantity could be updated when changing pages if any quantities changed in another tab.

I agree with your intent @ludoboludo, but with page jumps on load I think keeping the spinner in the "footer" area is probably best to ensure it is always visible.

switchVariants(event) {
if (event.target.tagName !== 'INPUT') {
return;
handleScrollIntoView(event) {
Copy link
Contributor Author

@lhoffbeck lhoffbeck Feb 3, 2025

Choose a reason for hiding this comment

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

I refactored switchVariants into handleScrollIntoView and handleSwitchVariantOnEnter for a few reasons:

  • the old "scroll into view" behavior didn't work for +/- buttons or tabbing to the pagination links
  • overly aggressive event binding that could lead to memory leaks

Event binding was arguably the bigger issue here, great example of a memory leak. The first issue is that switchVariants was an event handler for a focusin event on the table and was never unbound, meaning that switchEvents could get bound as many times as the focusin was triggered. The function then bound one of two keydown events to the input that was focused, and never removed these listeners either.

The new code extracts to 2 single-purpose event listeners.

  • handleSwitchVariantOnEnter is bound to a keydown on the table and determines which input to focus next if the event came from an enter
  • handleScrollIntoView is bound on keyup, and optionally scrolls the table if the event came from a tab or an enter

@@ -11,7 +11,7 @@
{% endcomment %}

<quantity-input class="quantity cart-quantity">
<button class="quantity__button" name="minus" type="button">
<button class="quantity__button" name="minus" type="button" data-target="decrement-{{ variant.id }}">
Copy link
Contributor Author

Choose a reason for hiding this comment

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

data-target enables us to refocus inputs after content is updated so keyboard order doesn't get goofed up

@lhoffbeck
Copy link
Contributor Author

@ludoboludo I just pushed up a change that simplified how we do event handling, poking around it seems like it resolved all the funky focus/scroll issues you were running into. Mind taking a look again?

@lhoffbeck lhoffbeck requested a review from ludoboludo February 3, 2025 15:55
Copy link

@oksanashopify oksanashopify left a comment

Choose a reason for hiding this comment

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

I tophatted changes and described workflows works as expected for me. Great work 🚀

[questions]

  1. Sorry if it's not related to changes in this PR. When we hit "remove" icon, there is no visual update for customer that quantity is being removed and customer just can continue to hit "remove" button. Video. Shout it have disabled or maybe loading state?

@lhoffbeck
Copy link
Contributor Author

  1. Sorry if it's not related to changes in this PR. When we hit "remove" icon, there is no visual update for customer that quantity is being removed and customer just can continue to hit "remove" button. Video. Shout it have disabled or maybe loading state?

Yeah agreed, that's pretty gross. I'll flag it with the team that owns the component to see if they want to do some UX exploration 👍

Copy link

@LA1CH3 LA1CH3 left a comment

Choose a reason for hiding this comment

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

Lacking storefront context but based off PR description LGTM!

Only thing I am unsure about whether its expected or not is that for a product without paginated variants, there is no pagination (as expected) but also no "Total items", "Product subtotal", "View cart" and "Remove all"- is that expected?

single.variant.vs.multi.variant.sf.mp4

@@ -55,15 +53,15 @@ if (!customElements.get('quick-add-bulk')) {

listenForActiveInput() {
if (!this.classList.contains('hidden')) {
this.getInput().addEventListener('focusin', (event) => event.target.select());
this.getInput()?.addEventListener('focusin', (event) => event.target.select());
Copy link

Choose a reason for hiding this comment

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

Nit but to remove some repetition in a few of these areas, you could do something like:

// or just rename `getInput` as a getter
get input() {
  return this.getInput();
}

listenForActiveInput() {
  if (!this.classList.contains('hidden') && this.input) {
  this.input.addEventListener(...);
  }
}

// same thing for `listenForKeydown`

this.totalBarPosition = window.innerHeight - this.getTotalBar().offsetHeight;
this.totalBar = this.getTotalBar();
if (this.totalBar) {
this.totalBarPosition = window.innerHeight - this.totalBar.offsetHeight;

window.addEventListener('resize', () => {
Copy link

Choose a reason for hiding this comment

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

Pre-existing issue, but we should probably return the event listener here so we can unsubscribe from the resize event when the quick order list is unmounted/detached.

this.allInputsArray = Array.from(this.querySelectorAll('input[type="number"]'));

this.querySelectorAll('quantity-input').forEach((qty) => {
const debouncedOnChange = debounce(this.onChange.bind(this), 250);
Copy link

Choose a reason for hiding this comment

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

Nit- can we store this debounce time value as a constant somewhere?

Copy link

Choose a reason for hiding this comment

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

I realize this is all pre-existing, but there's a huge amount of selectors just hard-coded and repeated throughout this file- would be great to store them all as constants/variables and just reference those.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, totally agree. I was trying to keep this PR as tight as possible to avoid introducing regressions.

Copy link
Contributor

@ludoboludo ludoboludo left a comment

Choose a reason for hiding this comment

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

Not a thorough review but from what I've checked, it didn't seem to introduce regressions.
A bit harder to test when not in production 😅

@lhoffbeck
Copy link
Contributor Author

Lacking storefront context but based off PR description LGTM!

Only thing I am unsure about whether its expected or not is that for a product without paginated variants, there is no pagination (as expected) but also no "Total items", "Product subtotal", "View cart" and "Remove all"- is that expected?

single.variant.vs.multi.variant.sf.mp4

Yeah good callout, this is existing behavior. My assumption is that the original devs built it like this because there's very little (no?) customer benefit to showing a quick order list with a single variant vs the buyer just using the main product form.

@lhoffbeck lhoffbeck force-pushed the update-quick-order-list-to-use-paginated-variants branch from 70f65ee to 770517c Compare February 13, 2025 21:14
add keyboard and mouse click differentiation for autofocus shift.

Update 1 translation file

Update 1 translation file

Update 2 translation files

Update 1 translation file

Update 1 translation file

Migrate to using sections with page param

Fix bug where refresh was broken

bugfix: don't clear loading state when there are still pending reqeusts

Fix pagenumber and focus state to work with renderSections

Cleanup

Fix quick add bulk bugs

Update 1 translation file

Update 3 translation files

Fix bugs when pagination links are not displayed or quick order list total is not shown

Bugfixes: don't scroll on input refocus, scroll top after changing page

Bugfix -- fix duplicated event listener binding + scroll on tab

Bugfix -- scroll container when in pop up view

Update 3 translation files

Update 2 translation files

Back out changes to quantity spinner

Minor cleanup

Update 2 translation files

Update 1 translation file

Update 1 translation file

Migrate to delegated event listeners to simplify and fix focus bugs

Update content type for cart json

PR feedback

Co-authored-by: Parsa Hemmati <parsa.hemmati@shopify.com>
@lhoffbeck lhoffbeck force-pushed the update-quick-order-list-to-use-paginated-variants branch from 770517c to 47c315e Compare February 24, 2025 13:54
@lhoffbeck lhoffbeck merged commit c661a79 into main Feb 24, 2025
8 checks passed
@lhoffbeck lhoffbeck deleted the update-quick-order-list-to-use-paginated-variants branch February 24, 2025 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants