David Geismar
David Geismar

Reputation: 3422

StimulusJS how to compute value in parent controller from values in different child controllers (cart -> cart items)

I am building a cart with stimulus. The cart recap page displays multiple cart items. A cart item has a unit price and a quantity. Each cart item component has a quantity selector, when the quantity number changes the total price of the cartitem element is recalculated totalCartItem = unitPrice x Quantity and the total price if the cart must also be recalculated totalCartPrice = sum(totalCartItem)

Here's a simplification of the html structure

<div class="cart" data-controller='cart'>
  <div class="cart-item" data-controller='cart-item'>
    <p class='unit-price'>20</p>
    <input type="number" name="quantity" value="1">
    <p class='total-price' data-target='cart-item.totalPrice' data-action="change->cart-item#update" data-target='cart-item.quantity'>20</p>
  </div>
  <div class="cart-item" data-controller='cart-item'>
    <p class='unit-price'>10</p>
    <input type="number" name="quantity" value="2" data-action="change->cart-item#update" data-target='cart-item.quantity'>
    <p class='total-price' data-target='cart-item.totalPrice'>20</p>
  </div>
  <div class="cart-total">
    40
  </div>
</div>

My cart item controller works perfectly fine and updates correctly the totalPriceFor a cart item in the UI.

export default class extends Controller {
  static targets = ["totalPrice", "quantity"]

  static values = {
    unitPrice: String,
  }

  connect() {
    console.log("hello from StimulusJS")
  }

  update(){
    this.totalPriceTarget.textContent = parseFloat(this.unitPriceValue) * parseInt(this.quantityTarget.value)
  }
}

However, I am now lost on how to update the totalCartPrice. I feel like this should be the responsability of the cartController that wrapps the cartItems elements, but I have no idea on what id the correct way to achieve this. I feel like I should add change->cart#update on each number input selector for quantity for each cart-item, but then what should I add to the cart#update method to recalculate the total from each individual cart item ?

export default class extends Controller {
  static targets = ["total"]
  connect() {
    console.log("hello from StimulusJS")
  }

  update(){
    // let details = event.detail;
    // this.cartItemChildren.forEach((item) => console.log(item.totalValue))
  }
}

Upvotes: 2

Views: 1257

Answers (1)

LB Ben Johnston
LB Ben Johnston

Reputation: 5186

As per the Stimulus documentation, it is always best to start with the HTML. By extension it is best to structure your HTML in an accessible way and see what the browser gives you without reinventing things.

When you are building forms, it is best to use the form element, even if this form is not being submitted. https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement

There is also a very useful output element that helps you assign an output value based on some input value. https://developer.mozilla.org/en-US/docs/Web/HTML/Element/output#attr-for

Finally, there is a DOM API for the form element that lets you access various parts of the form via their name attribute, this is form.elements. https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements

Bringing this together we can start with the following HTML...

HTML

  • We are simply attaching our controller action to the form as a whole, so we can listen to any change to the inputs within the form.
  • One controller for this case, while the docs to recommend smaller granular controllers, the smallest unit content here appears to be the form as a whole.
  • Each input has a unique ID and each output element references the input via this id using the for attribute - this will be better for accessibility but comes in handy when we want to parse the values and update outputs.
  • We also have an additional data attribute on the unit price that uses the same id, note that this data attribute is nothing special but just gives us a way to 'find' the relevant price. No need to create a controller target for this simple case.
  • Note: This assumes you do not have to support IE11 (as the output element will not work in that browser).
<form class="cart" data-controller='cart' data-action="change->cart#updateTotals">
  <div class="cart-item">
    <span class='unit-price' data-price-for="cart-item-1">20</span>
    x
    <input type="number" name="quantity" value="1" id="cart-item-1">
    =
    <output class="total-price" for="cart-item-1" name="item-total">20</output>
  </div>
  <div class="cart-item">
    <span class='unit-price' data-price-for="cart-item-2">10</span>
    x
    <input type="number" name="quantity" value="1" id="cart-item-2">
    =
    <output class="total-price" for="cart-item-2" name="item-total">10</output>
  </div>
  <div class="cart-total">
    Total: <span data-cart-target="total">30</span>
  </div>
</form>

JavaScript (Controller)

  • Our controller does not really need anything other than the grand total target, as we can use this.element on the base form's attached controller.
  • We are avoiding a generic update method and trying to be more specific with updateTotals.
  • We use the elements API on the form to get the elements with the name 'item-total' (note that output names do not get submitted). https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements
  • Once we have each output element, we can find the relevant input element via the for and the input's id and then the data attribute to find the price.
  • We then calculate a grand total and finally update all the values in the DOM.
  • Note: Number parsing is very basic, best to add some safety here, also we are assuming you do not need to support IE11.
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
  static targets = ['total'];

  connect() {
    this.updateTotals();
  }

  updateTotals() {
    // parse the form elements to get all data
    // https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements

    const cartItems = [...this.element.elements['item-total']].map(
      (outputElement) => {
        const forId = outputElement.getAttribute('for');
        const priceElement = document.querySelector(`[data-price-for='${forId}']`);
        const price = Number(priceElement.textContent);
        const quantity = Number(document.getElementById(forId).value);
        return { outputElement, total: quantity * price };
      }
    );

    // calculate the grand total
    const grandTotal = cartItems.reduce(
      (runningTotal, { total }) => runningTotal + total,
      0
    );

    // update the grand total
    this.totalTarget.textContent = grandTotal;

    // update totals for each cart item
    cartItems.forEach(({ outputElement, total }) => {
      outputElement.textContent = total;
    });

    console.log('form updated', { grandTotal, cartItems });
  }
}

Upvotes: 3

Related Questions