Migrate all inline JS to Stimulus controllers

Add stimulus-rails gem and wire up 7 controllers:
- measurements_view_controller: view toggle (compact/wide) via localStorage
- measurements_controller: grouped rows MutationObserver
- charts_controller: Plotly chart rendering
- form_controller: keyboard shortcuts (Escape/Enter) and submit validation
- details_controller: quantity picker state, focusout close, MutationObserver
- readout_unit_controller: default unit button enable/disable + PATCH submission
- drag_controller: drag-and-drop for quantity reparenting and unit rebasing

Remove all inline onclick/onkeydown/ondrag*/onsubmit handlers from templates.
Remove all window.* global exports from application.js.
Remove bare <script> block from measurements/_form.html.erb.
Remove turbo:load listeners for behavior now in controller connect().

application.js now only contains: Turbo Stream custom action definitions
and the showPage visibility listener.

Document Stimulus conventions in CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 13:44:44 +00:00
parent 74341b6b38
commit fee3ce8627
29 changed files with 365 additions and 334 deletions

View File

@@ -1,6 +1,7 @@
<%= tabular_fields_for @readout, form: form_tag do |form| %>
<%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {form: form_tag, hidden_row: hidden_row, link: link} do %>
<%- tag.tr id: row, class: "form",
data: {controller: 'form', action: 'keydown->form#processKey',
form: form_tag, hidden_row: hidden_row, link: link} do %>
<td><%= @readout.quantity %></td>
<td class="ralign">
<%= form.number_field :value, required: true, autofocus: true %>

View File

@@ -6,8 +6,9 @@
id: form_tag do |form| %>
<table class="items-table">
<tbody>
<%= tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {form: form_tag, hidden_row: hidden_row} do %>
<%= tag.tr id: row, class: "form",
data: {controller: 'form', action: 'keydown->form#processKey',
form: form_tag, hidden_row: hidden_row} do %>
<td><%= @readout.quantity %></td>
<td class="ralign">
<%= form.number_field :value, required: true, autofocus: true %>

View File

@@ -1,6 +1,6 @@
<%= tabular_form_with model: Measurement.new, id: :measurement_form,
class: 'topside-area flex vertical center',
html: {onkeydown: 'formProcessKey(event)'} do |form| %>
html: {data: {controller: 'form', action: 'keydown->form#processKey'}} do |form| %>
<table class="items-table center">
<tbody id="readouts">
@@ -17,17 +17,18 @@
<%# TODO: right-click selection; unnecessary with hierarchical tags? %>
<details id="quantity_select" class="center hexpand" open
onkeydown="detailsProcessKey(event)">
data-controller="details"
data-action="focusout->details#close change->details#change keydown->details#processKey">
<summary autofocus>
<!-- TODO: Set content with CSS when span empty to avoid duplication -->
<span data-prompt="<%= t('.select_quantity') %>">
<span data-prompt="<%= t('.select_quantity') %>" data-details-target="countLabel">
<%= t('.select_quantity') %>
</span>
<%= image_button_tag t(:apply), "update", name: nil, disabled: true,
formaction: new_readout_path, formmethod: :get, formnovalidate: true,
data: {turbo_stream: true} %>
data: {turbo_stream: true, details_target: 'submitButton'} %>
</summary>
<ul><%= quantities_check_boxes(@quantities) %></ul>
<ul data-details-target="list"><%= quantities_check_boxes(@quantities) %></ul>
</details>
<div class="flex reverse">
@@ -36,10 +37,3 @@
class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</div>
<% end %>
<script>
quantity_select.addEventListener('focusout', detailsClose)
quantity_select.addEventListener('change', detailsChange)
detailsObserver.observe(quantity_select.querySelector('ul'),
{subtree: true, attributeFilter: ['disabled']})
</script>

View File

@@ -1,5 +1,5 @@
<%# TODO: show hint when no quantities/units defined %>
<div class="rightside-area buttongrid">
<div class="rightside-area buttongrid" data-controller="measurements-view">
<% if current_user.at_least(:active) %>
<%= image_link_to t('.new_measurement'), 'plus-outline', new_measurement_path,
id: :new_measurement_link, onclick: 'this.blur();',
@@ -7,16 +7,17 @@
<% end %>
<%= image_button_tag '', 'view-rows', name: nil, type: 'button',
class: 'view-toggle', title: t('.view_compact'),
data: {view: 'compact'}, onclick: "setMeasurementsView('compact')" %>
data: {view: 'compact', action: 'click->measurements-view#set',
'measurements-view-name-param': 'compact'} %>
<%= image_button_tag '', 'view-columns', name: nil, type: 'button',
class: 'view-toggle', title: t('.view_wide'),
data: {view: 'wide'}, onclick: "setMeasurementsView('wide')" %>
data: {view: 'wide', action: 'click->measurements-view#set',
'measurements-view-name-param': 'wide'} %>
</div>
<div class="main-area measurements-section">
<%= tag.div id: :measurement_edit_form %>
<table class="items-table measurements-compact">
<table class="items-table measurements-compact" data-controller="measurements">
<thead>
<tr>
<th><%= Quantity.model_name.human %></th>
@@ -29,7 +30,7 @@
<% end %>
</tr>
</thead>
<tbody id="measurements">
<tbody id="measurements" data-measurements-target="tbody">
<%= render(partial: 'readout', collection: @measurements, as: :readout) || render_no_items %>
</tbody>
</table>
@@ -38,4 +39,3 @@
<%= render 'wide_table', wide_groups: @wide_groups, wide_quantities: @wide_quantities %>
</div>
</div>