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

Add product puppeteer class and simplify component update behavior #3289

Merged
merged 1 commit into from
May 28, 2024

Conversation

lhoffbeck
Copy link
Contributor

@lhoffbeck lhoffbeck commented Feb 21, 2024

⚠️ This PR builds on the work to support combined listings and 2k variants #3378

tl;dr - this PR primarily copies code out of the variant picker class in global.js into product-info.js, which has been promoted to act as a wrapper for Product.

Why are these changes introduced?

This PR came out of a conversation with @tyleralsbury earlier this year while working on adding support for combined listings / 2k variants. In that PR, the VariantSelects class needs to swap out the page content for its product when the selected variant is associated with a different Combined Listing child product. VariantSelects can be embedded in a few different product contexts (main product, featured product, quick add modal), and I was running into an issue where the class needed some pretty janky logic to determine its wrapping context (here and here). For a more stable architecture, child components shouldn't care what context they are rendered in.

An additional problem is that if components need to be updated when an event in a sibling happens, there wasn't a great "namespaced" context to manage the update. We had two primary patterns for handling this:

Directly update sibling content (link)

class VariantSelects extends HTMLElement {
  
  onVariantChange(event) {
    ...
    this.updatePickupAvailability();
    ...
  }

  updatePickupAvailability() {
    const pickUpAvailability = document.querySelector('pickup-availability');
    if (!pickUpAvailability) return;

    if (this.currentVariant && this.currentVariant.available) {
      pickUpAvailability.fetchAvailability(this.currentVariant.id);
    } else {
      pickUpAvailability.removeAttribute('available');
      pickUpAvailability.innerHTML = '';
    }
  }
}

Publish an event with the section, sibling subscription triggers update (link)

class VariantSelects extends HTMLElement {
    ...
    publish(PUB_SUB_EVENTS.variantChange, {
      data: {
        sectionId,
        html,
        variant: this.currentVariant,
      },
    });
  ...
}

class ProductInfo extends HTMLElement {
  connectedCallback() {
      this.variantChangeUnsubscriber = subscribe(PUB_SUB_EVENTS.variantChange, (event) => {
      const sectionId = this.dataset.originalSection ? this.dataset.originalSection : this.dataset.section;

      // check whether the event came from this ProductInfo's section
      if (event.data.sectionId !== sectionId) return;
      this.updateQuantityRules(event.data.sectionId, event.data.html);
      this.setQuantityBoundries();
    });
  }
}

What approach did you take?

We have 2 primary issues: child components need to be context aware, and handling product component updates was complicated and inconsistent.

To solve both of these problems, I introduced a product class that wraps the entire rendered product and serves as an event handling puppeteer.

We already had a product wrapper class called ProductInfo that wrapped part of the rendered product; I moved it higher up the tree and extracted component update logic into it.

Other considerations

I left a comment, but we possibly could decompose further by moving the old ProductInfo content into a QuantityForm class.

Visual impact on existing themes

None

Testing steps/scenarios

Here is a zip file for these theme changes: https://github.com/Shopify/dawn/archive/refs/heads/add-product-wrapper-class.zip

  • Option value switching still works as expected
    • Switching between option values
    • Switching to an option value that has no associated variant
    • Switching between combined listings children
  • Product recommendations load when switching between combined listings children
  • Quick order list is updated when switching between combined listings children
  • Quick add modal
    • works as expected for a normal product
    • works as expected for a combined listing product
  • Quantity and volume price rules. (A catalog is set up on the shop, feel free to add yourself as a user or reach out to me for a test account)

Demo links

Checklist

@thagxt
Copy link

thagxt commented Feb 21, 2024

This proposal really catches my attention. This PR could be the perfect opportunity to make a clear cut decision: use data attributes or IDs for JavaScript targeting and reserve classes solely for styling. I believe this would be a huge help for developers leveraging Dawn as their starter theme.

Making it standard practice to target elements in JavaScript using only IDs or data attributes could seriously streamline things. Right now, it's a bit of a jumble—sometimes you're working with classes, other times with elements, data attributes, or IDs, and it can get pretty confusing. Especially if you're trying to use Dawn as a base theme and want to tidy it up without losing any JavaScript functionality, it's hard to know which class is essential for the JS to work.

@lhoffbeck lhoffbeck changed the base branch from main to support-combined-listings February 21, 2024 21:44
@lhoffbeck lhoffbeck force-pushed the add-product-wrapper-class branch 5 times, most recently from cea17d5 to c2802fd Compare February 22, 2024 20:54
@@ -995,75 +995,35 @@ customElements.define('slideshow-component', SlideshowComponent);
class VariantSelects extends HTMLElement {
constructor() {
super();
this.addEventListener('change', this.handleProductUpdate);
this.initializeProductSwapUtility();
}

Copy link
Contributor Author

@lhoffbeck lhoffbeck Feb 22, 2024

Choose a reason for hiding this comment

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

This is the primary change of this PR: extracting product-update logic out of VariantSelects to a wrapper class.

Instead of this class mutating siblings, it now only updates children and publishes an event that indicates a variant change started. The new ProductInfo parent then mutates its children. This separation of logic hopefully makes it easier to understand what controls updates to a component (i.e. itself or a parent vs itself or one of its siblings).

This also enables us to eliminate some gnarly selectors from here and here.

this.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.

I migrated this functionality from the VariantSelects class, where it was called in the variant change event

Copy link
Contributor Author

@lhoffbeck lhoffbeck Feb 22, 2024

Choose a reason for hiding this comment

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

This class was used for quantity input event handlers, and was rendered further down the product template tree. I repurposed it to be a general Product wrapper class and migrated the onVariantChange handler and (related functions) out of the VariantSelects class.

⚠️ I think there's an argument to be made that the quantity input events shouldn't be in this class at all, and should be their own custom element instead. I did a spike of what that could look like and welcome your feedback on whether that's worth doing now :)

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 stylistic note, I made functions in this class private if they don't seem like they'd be useful to access from outside of this class.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Update -- to maintain consistency with the rest of Dawn, I rolled back the private class function change and everything is now public.

Comment on lines -8 to -9
this.currentVariant = this.querySelector('.product-variant-id');
this.submitButton = this.querySelector('[type="submit"]');
Copy link
Contributor Author

Choose a reason for hiding this comment

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

currentVariant can be grabbed from the product form class, submitButton wasn't used anywhere.

@@ -452,8 +451,7 @@
{% render 'product-variant-picker',
product: product,
block: block,
product_form_id: product_form_id,
update_url: 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.

We don't update_url here anymore because this logic lives on the product

Comment on lines -17 to -20
data-url="{{ product.url }}"
data-product-id="{{ product.id }}"
{% if update_url == false %}
data-update-url="false"
{% endif %}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

All of this is now handled by the product class

@lhoffbeck lhoffbeck marked this pull request as ready for review February 23, 2024 16:08
@lhoffbeck lhoffbeck changed the title [WIP] Add product puppeteer class and simplify component update behavior Feb 23, 2024
@lhoffbeck lhoffbeck force-pushed the support-combined-listings branch 2 times, most recently from 8303103 to 959f198 Compare February 27, 2024 16:27
@lhoffbeck lhoffbeck requested a review from a team March 1, 2024 14:49
@lhoffbeck lhoffbeck requested a review from MiniLuke March 12, 2024 17:04
Copy link

@MiniLuke MiniLuke left a comment

Choose a reason for hiding this comment

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

I like this approach. I think the tophatting I did on the combined listing/2k variant PR covers this one.

Base automatically changed from support-combined-listings to main March 19, 2024 14:23
lhoffbeck added a commit that referenced this pull request May 23, 2024
…ing product-info class and migrating product-update specific logic out of the VariantSelects class. A product wrapper enables children to more trivially perform global updates by providing a heirarchical "namespace"--i.e. child publishes event, parent captures and declaratively updates other children VS child updates siblings. By extracting the VariantSelects onChange logic to use this pattern, VariantSelects is able to be a single-purpose class, it is easier to understand why siblings are updated, and we were able to eliminate some really gross logic that handled variant change updates differently depending on the wrapping context.
lhoffbeck added a commit that referenced this pull request May 23, 2024
…ing product-info class and migrating product-update specific logic out of the VariantSelects class. A product wrapper enables children to more trivially perform global updates by providing a heirarchical "namespace"--i.e. child publishes event, parent captures and declaratively updates other children VS child updates siblings. By extracting the VariantSelects onChange logic to use this pattern, VariantSelects is able to be a single-purpose class, it is easier to understand why siblings are updated, and we were able to eliminate some really gross logic that handled variant change updates differently depending on the wrapping context.
lhoffbeck added a commit that referenced this pull request May 23, 2024
…ing product-info class and migrating product-update specific logic out of the VariantSelects class. A product wrapper enables children to more trivially perform global updates by providing a heirarchical "namespace"--i.e. child publishes event, parent captures and declaratively updates other children VS child updates siblings. By extracting the VariantSelects onChange logic to use this pattern, VariantSelects is able to be a single-purpose class, it is easier to understand why siblings are updated, and we were able to eliminate some really gross logic that handled variant change updates differently depending on the wrapping context.

fix typo
lhoffbeck added a commit that referenced this pull request May 23, 2024
…ing product-info class and migrating product-update specific logic out of the VariantSelects class. A product wrapper enables children to more trivially perform global updates by providing a heirarchical "namespace"--i.e. child publishes event, parent captures and declaratively updates other children VS child updates siblings. By extracting the VariantSelects onChange logic to use this pattern, VariantSelects is able to be a single-purpose class, it is easier to understand why siblings are updated, and we were able to eliminate some really gross logic that handled variant change updates differently depending on the wrapping context.
lhoffbeck added a commit that referenced this pull request May 23, 2024
…ing product-info class and migrating product-update specific logic out of the VariantSelects class. A product wrapper enables children to more trivially perform global updates by providing a heirarchical "namespace"--i.e. child publishes event, parent captures and declaratively updates other children VS child updates siblings. By extracting the VariantSelects onChange logic to use this pattern, VariantSelects is able to be a single-purpose class, it is easier to understand why siblings are updated, and we were able to eliminate some really gross logic that handled variant change updates differently depending on the wrapping context.
this.cartUpdateUnsubscriber?.();
}

#initializeProductSwapUtility() {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

From here through #setQuantityBoundries was migrated from VariantSelects with minimal changes.

@lhoffbeck lhoffbeck force-pushed the add-product-wrapper-class branch 2 times, most recently from 474be3d to 4f3b617 Compare May 23, 2024 16:29
@@ -106,7 +106,6 @@ if (!customElements.get('quick-order-list')) {
}

cartUpdateUnsubscriber = undefined;
sectionRefreshUnsubscriber = undefined;
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 originally added this code so that the component would self-refresh when swapping between combined listing siblings.

This is no longer necessary, since we now replace the entire content of <main> when swapping between siblings.

@@ -1386,39 +1101,24 @@ class ProductRecommendations extends HTMLElement {
}

connectedCallback() {
this.initializeRecommendations();

this.unsubscribeFromSectionRefresh = subscribe(PUB_SUB_EVENTS.sectionRefreshed, (event) => {
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 originally added this code so that the component would self-refresh when swapping between combined listing siblings.

This is no longer necessary, since we now replace the entire content of <main> when swapping between siblings.

this.submitButton.removeAttribute('disabled');
this.submitButtonText.textContent = window.variantStrings.addToCart;
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

toggleSubmitButton logic was pulled out of the VariantSelects class

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 are because of changing how main-product section is wrapped in a product-info component

id="MainProduct-{{ section.id }}"
class="section-{{ section.id }}-padding gradient color-{{ section.settings.color_scheme }}"
data-section="{{ section.id }}"
data-product-id="{{ product.id }}"
data-update-url="true"
data-url="{{ product.url }}"
>
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Worth flagging, this node was changed from a section to a product-info

assets/product-form.js Outdated Show resolved Hide resolved

connectedCallback() {
if (!this.input) return;
this.#initializeProductSwapUtility();
Copy link
Contributor

Choose a reason for hiding this comment

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

while I like the idea of clearly mentioning which methods are private and won't be needed outside of the class logic, we're only introducing it here and it creates inconsistency with the rest of the JS.

Not a big deal but just having multiple take/approach in the same codebase adds to its confusion when reading through it I think.

assets/product-info.js Outdated Show resolved Hide resolved
sections/main-product.liquid Show resolved Hide resolved
lhoffbeck added a commit that referenced this pull request May 28, 2024
…ing product-info class and migrating product-update specific logic out of the VariantSelects class. A product wrapper enables children to more trivially perform global updates by providing a heirarchical "namespace"--i.e. child publishes event, parent captures and declaratively updates other children VS child updates siblings. By extracting the VariantSelects onChange logic to use this pattern, VariantSelects is able to be a single-purpose class, it is easier to understand why siblings are updated, and we were able to eliminate some really gross logic that handled variant change updates differently depending on the wrapping context.
@lhoffbeck lhoffbeck requested a review from ludoboludo May 28, 2024 13:16
lhoffbeck added a commit that referenced this pull request May 28, 2024
…ing product-info class and migrating product-update specific logic out of the VariantSelects class. A product wrapper enables children to more trivially perform global updates by providing a heirarchical "namespace"--i.e. child publishes event, parent captures and declaratively updates other children VS child updates siblings. By extracting the VariantSelects onChange logic to use this pattern, VariantSelects is able to be a single-purpose class, it is easier to understand why siblings are updated, and we were able to eliminate some really gross logic that handled variant change updates differently depending on the wrapping context.

Refetch entire page on product swap

Remove unused unsubscriber function

Stability improvements for quick-add modal

PR feedback - fixed padding + minor tweaks

Un-privatize class methods for consistency with the rest of Dawn
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.

This seem good 👌
Just one suggested nitpick, and I guess there is a conflict to fix.

assets/base.css Outdated Show resolved Hide resolved
…ing product-info class and migrating product-update specific logic out of the VariantSelects class. A product wrapper enables children to more trivially perform global updates by providing a heirarchical "namespace"--i.e. child publishes event, parent captures and declaratively updates other children VS child updates siblings. By extracting the VariantSelects onChange logic to use this pattern, VariantSelects is able to be a single-purpose class, it is easier to understand why siblings are updated, and we were able to eliminate some really gross logic that handled variant change updates differently depending on the wrapping context.

Refetch entire page on product swap

Remove unused unsubscriber function

Stability improvements for quick-add modal

PR feedback - fixed padding + minor tweaks

Un-privatize class methods for consistency with the rest of Dawn

Move css to be in section-main-product.css
@lhoffbeck lhoffbeck merged commit ecdc76b into main May 28, 2024
8 checks passed
@lhoffbeck lhoffbeck deleted the add-product-wrapper-class branch May 28, 2024 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants