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

@@ -0,0 +1,62 @@
import { Controller } from "@hotwired/stimulus"
// Shared across all instances — drag spans multiple elements
let lastEnterTime
export default class extends Controller {
static values = {
dragPath: String,
dropId: String,
dropIdParam: String
}
start(event) {
lastEnterTime = event.timeStamp
this.element.closest("table").querySelectorAll("thead tr").forEach(tr => {
tr.toggleAttribute("hidden")
})
event.dataTransfer.setData("text/plain", this.dragPathValue)
const rect = this.element.getBoundingClientRect()
event.dataTransfer.setDragImage(this.element, event.x - rect.left, event.y - rect.top)
event.dataTransfer.dropEffect = "move"
}
end(event) {
this.leave(event)
this.element.closest("table").querySelectorAll("thead tr").forEach(tr => {
tr.toggleAttribute("hidden")
})
}
enter(event) {
this.leave(event)
lastEnterTime = event.timeStamp
document.getElementById(this.dropIdValue)?.classList.add("dropzone")
}
over(event) {
event.preventDefault()
}
leave(event) {
if (event.timeStamp <= lastEnterTime) return
this.element.closest("table").querySelectorAll(".dropzone").forEach(tr => {
tr.classList.remove("dropzone")
})
}
drop(event) {
event.preventDefault()
const id = this.dropIdValue.split("_").pop()
const form = document.createElement('form')
form.action = event.dataTransfer.getData("text/plain")
form.method = 'post'
form.dataset.turboStream = 'true'
const input = document.createElement('input')
input.type = 'hidden'; input.name = this.dropIdParamValue; input.value = id
form.appendChild(input)
form.addEventListener('turbo:submit-end', () => form.remove())
document.body.appendChild(form)
form.requestSubmit()
}
}