Simple input calculation with Stimulus.js


I've started a new Rails 6 app and want to try out Stimulus.js instead of a more complex JS library such as VueJs or ReactJs. Stimulus.js is created by Basecamp, creator of Rails so it is supposed to easily integrated with Rails as I would like to use Rails helper functions in views instead of writing manual forms in VueJs with extra API endpoints for it.

As it states on the homepage

A modest JavaScript framework for the HTML you already have.

Stimulus.js doesn't seek to replace good-old-HTML template like Vue/React. Instead it blends itself into existing HTML code and connect it to controllers - the enities that hold JS interactive code & functions.

There is a simple example on Stimulus.js home page but here I want to demonstrate a little more complicated example.

The problem

I have a form with three fields: gross_amount, deduction_amount and net_amount
The logic as follow

  • net_amount = gross_amount - deduction_amount
  • if gross_amount or deduction amount is change, updated net_amount value
  • if net_amount is changed, updated deduction_amount value
  • 3 fields should have currency input (nicely formatted input as user typing)

Step-by-step solution

Setup Stimulus.js

Add Stimulus npm package

yarn add stimulus

Add Stimulus config to app/javascripts/packs/application.js

import { Application } from 'stimulus'
import { definitionsFromContext } from 'stimulus/webpack-helpers'

const application = Application.start()
// controllers will be in app/javascripts/controllers
const context = require.context('../controllers', true, /\.js$/)
application.load(definitionsFromContext(context))

Rails form

This is a basic compensation form, I have retracted all custom tags/styles:

<%= form_for @compensation do |f| %>
  <%= f.label :gross_amount %>
  <%= f.text_field :gross_amount %>

  <%= f.label :deduction_amount %>
  <%= f.text_field :deduction_amount %>

  <%= f.label :net_amount %>
  <%= f.text_field :net_amount %>
<% end %>

We want to connect this form to a Stimulus controller where we will perform automatic update on net_amount and deduction_amount based on input values. Let's update the form with Stimulus attributes

<%= form_for @compensation, html: { "data-controller" => "compensation-form" } do |f| %>
  <%= f.label :gross_amount %>
  <%= f.text_field :gross_amount, data: {
    target: "compensation-form.grossAmount",
    action: "change->compensation-form#updateNetAmount"
  } %>

  <%= f.label :deduction_amount %>
  <%= f.text_field :deduction_amount, data: {
    target: "compensation-form.deductionAmount",
    action: "change->compensation-form#updateNetAmount"
  } %>

  <%= f.label :net_amount %>
  <%= f.text_field :net_amount, data: {
    target: "compensation-form.netAmount",
    action: "change->compensation-form#updateDeductionAmount"
  } %>
<% end %>

Stimulus controller

The respective controller for "data-controller" => "compensation-form" is at app/javascripts/controllers/compensation_form_controller.js

import { Controller } from 'stimulus'

export default class extends Controller {
  static targets = [ 'grossAmount', 'deductionAmount', 'netAmount' ]

  updateNetAmount () {
    const netAmount = this.grossAmount() - this.deductionAmount()
    this.netAmountTarget.value = netAmount
  }

  updateDeductionAmount () {
    const deductionAmount = this.grossAmount() - this.netAmount()
    this.deductionAmountTarget.value = deductionAmount
  }

  grossAmount () {
    return parseInt(this.grossAmountTarget)
  }

  deductionAmount () {
    return parseInt(this.deductionAmountTarget)
  }

  netAmount () {
    return parseInt(this.netAmountTarget)
  }
}

This is the result:

It works as expected but it doesn't look like with currency like VND.

Let's enhance the inputs with autoNumeric

import { Controller } from 'stimulus'
import AutoNumeric from 'autonumeric'

export default class extends Controller {
  static targets = [ 'grossAmount', 'deductionAmount', 'netAmount' ]

  connect () {
    const autoNumericOptionsVND = {
      digitGroupSeparator        : ',',
      decimalCharacter           : '.',
      decimalCharacterAlternative: ',',
      currencySymbol             : '\u202f₫',
      currencySymbolPlacement    : AutoNumeric.options.currencySymbolPlacement.suffix,
      roundingMethod             : 'D',
      decimalPlaces              : 0,
      selectNumberOnly           : true,
      unformatOnSubmit           : true
    }

    new AutoNumeric(this.grossAmountTarget, autoNumericOptionsVND)
    new AutoNumeric(this.deductionAmountTarget, autoNumericOptionsVND)
    new AutoNumeric(this.netAmountTarget, autoNumericOptionsVND)
  }

  updateNetAmount () {
    const netAmount = this.grossAmount() - this.deductionAmount()
    AutoNumeric.getAutoNumericElement(this.netAmountTarget).set(netAmount)
  }

  updateDeductionAmount () {
    const deductionAmount = this.grossAmount() - this.netAmount()
    AutoNumeric.getAutoNumericElement(this.deductionAmountTarget).set(deductionAmount)
  }

  unformatAmountFields () {
    AutoNumeric.getAutoNumericElement(this.grossAmountTarget).formUnformat()
    AutoNumeric.getAutoNumericElement(this.deductionAmountTarget).formUnformat()
    AutoNumeric.getAutoNumericElement(this.netAmountTarget).formUnformat()
  }

  grossAmount () {
    return AutoNumeric.getAutoNumericElement(this.grossAmountTarget).getNumber()
  }

  deductionAmount () {
    return AutoNumeric.getAutoNumericElement(this.deductionAmountTarget).getNumber()
  }

  netAmount () {
    return AutoNumeric.getAutoNumericElement(this.netAmountTarget).getNumber()
  }
}

There is a small caveat with autoNumeric: it doesn't change back to raw value when submit (at least with the Rails form above). I have to manually revert input values before submit with unformatAmountFields() method. We need to trigger this on submit thus we need to add action to the form

<%= form_for @compensation, html: {
  "data-controller" => "compensation-form",
  "data-action" => "submit->compensation-form#unformatAmountFields"
} do |f| %>

This is the final result:

Look neat, doesn't it ?

Conclusion

My first impression working with Stimulus.js is quite positive. I'm looking forward to using Alpinejs / Stimulus.js and framework-independent JS packages instead of being locked into a specific JS framework for my upcoming projects.