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
ordeduction
amount is change, updatednet_amount
value - if
net_amount
is changed, updateddeduction_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.