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,182 +1,15 @@
// Configure your import map in config/importmap.rb. Read more:
// https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
/* Hide page before loaded for testing purposes */
function showPage(event) {
document.documentElement.style.visibility="visible"
function showPage() {
document.documentElement.style.visibility = "visible"
}
document.addEventListener('turbo:load', showPage)
function groupMeasurements() {
var tbody = document.getElementById('measurements');
if (!tbody) return;
var prevTakenAt = null;
Array.from(tbody.querySelectorAll('tr[data-taken-at]'))
.filter(function(row) { return row.style.display !== 'none' })
.forEach(function(row) {
var takenAt = row.dataset.takenAt;
row.classList.toggle('grouped', takenAt !== null && takenAt === prevTakenAt);
prevTakenAt = takenAt;
});
}
function buildCharts() {
var container = document.getElementById('measurements-charts');
var dataEl = document.getElementById('charts-data');
if (!container || !dataEl) return;
var readouts = JSON.parse(dataEl.textContent);
container.innerHTML = '';
if (readouts.length === 0) return;
// Data arrives sorted by taken_at from the server; group into per-quantity traces
var quantities = new Map();
readouts.forEach(function(r) {
if (!r.takenAt) return;
if (!quantities.has(r.quantityId)) {
quantities.set(r.quantityId, { name: r.quantityName, unit: r.unit, x: [], y: [] });
}
var q = quantities.get(r.quantityId);
q.x.push(r.takenAt);
q.y.push(r.value);
});
var traces = [];
quantities.forEach(function(q) {
traces.push({
x: q.x,
y: q.y,
mode: 'lines+markers',
type: 'scatter',
name: q.name + ' (' + q.unit + ')',
marker: { size: 5 }
});
});
var div = document.createElement('div');
div.className = 'chart-panel';
container.appendChild(div);
Plotly.newPlot(div, traces, {
xaxis: { type: 'date', tickformat: '%Y-%m-%d %H:%M' },
yaxis: {},
margin: { t: 20, r: 20, b: 80, l: 60 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
font: { family: 'system-ui' },
legend: { orientation: 'h', y: -0.25 }
}, { responsive: true, displayModeBar: false });
}
function getMeasurementsView() {
return localStorage.getItem('measurements-view') || 'compact';
}
function applyMeasurementsView(view) {
document.body.dataset.measurementsView = view;
}
function setMeasurementsView(view) {
localStorage.setItem('measurements-view', view);
applyMeasurementsView(view);
}
window.setMeasurementsView = setMeasurementsView
document.addEventListener('turbo:load', function() {
if (document.getElementById('charts-data')) {
buildCharts();
return;
}
var tbody = document.getElementById('measurements');
if (!tbody) return;
groupMeasurements();
applyMeasurementsView(getMeasurementsView());
new MutationObserver(function() {
groupMeasurements();
}).observe(tbody, {
childList: true, subtree: true,
attributes: true, attributeFilter: ['style']
});
})
function detailsChange(event) {
var target = event.currentTarget
var count = target.querySelectorAll('input:checked:not([disabled])').length
var span = target.querySelector('summary > span')
var button = target.querySelector('button')
if (count > 0) {
span.textContent = count + ' selected';
Turbo.StreamElement.prototype.enableElement(button)
} else {
span.textContent = span.getAttribute('data-prompt')
Turbo.StreamElement.prototype.disableElement(button)
}
}
window.detailsChange = detailsChange
/* Close open <details> when focus lost */
function detailsClose(event) {
if (!event.relatedTarget ||
event.relatedTarget.closest("details") != event.currentTarget) {
event.currentTarget.removeAttribute("open")
}
}
window.detailsClose = detailsClose
window.detailsObserver = new MutationObserver((mutations) => {
mutations[0].target.dispatchEvent(new Event('change', {bubbles: true}))
});
function readoutUnitChanged(select) {
var button = select.closest('tr').querySelector('.set-default-unit');
if (select.value && select.value !== select.dataset.defaultUnitId) {
Turbo.StreamElement.prototype.enableElement(button);
} else {
Turbo.StreamElement.prototype.disableElement(button);
}
}
window.readoutUnitChanged = readoutUnitChanged
function setDefaultUnit(button) {
var select = button.closest('tr').querySelector('select[data-default-unit-id]');
var form = document.createElement('form');
form.action = button.dataset.path;
form.method = 'post';
form.dataset.turboStream = 'true';
var methodInput = document.createElement('input');
methodInput.type = 'hidden'; methodInput.name = '_method'; methodInput.value = 'patch';
var unitInput = document.createElement('input');
unitInput.type = 'hidden'; unitInput.name = 'quantity[default_unit_id]'; unitInput.value = select.value;
form.appendChild(methodInput);
form.appendChild(unitInput);
form.addEventListener('turbo:submit-end', function(event) {
if (event.detail.success) {
select.dataset.defaultUnitId = select.value;
readoutUnitChanged(select);
}
form.remove();
});
document.body.appendChild(form);
form.requestSubmit();
}
window.setDefaultUnit = setDefaultUnit
function formValidate(event) {
var id = event.submitter.getAttribute("data-validate")
if (!id) return;
var input = document.getElementById(id)
if (!input.checkValidity()) {
input.reportValidity()
event.preventDefault()
}
}
window.formValidate = formValidate
/* Turbo stream actions */
Turbo.StreamElement.prototype.disableElement = function(element) {
@@ -239,112 +72,3 @@ Turbo.StreamActions.unselect = function() {
this.enableElement(e)
})
}
function formProcessKey(event) {
switch (event.key) {
case "Escape":
event.currentTarget.querySelector("a[name=cancel]").click()
break
case "Enter":
event.currentTarget.querySelector("button[name=button]").click()
event.preventDefault()
break
}
}
window.formProcessKey = formProcessKey
function detailsProcessKey(event) {
// TODO: up/down arrows to move focus to prev/next line
switch (event.key) {
case "Escape":
if (event.currentTarget.hasAttribute("open")) {
event.currentTarget.removeAttribute("open")
event.stopPropagation()
}
break
case "Enter":
var button = event.currentTarget.querySelector("button:not([disabled])")
if (button) {
button.click()
// Autofocus won't be respected unless target is blurred
event.target.blur()
event.preventDefault()
event.stopPropagation()
}
break
}
}
window.detailsProcessKey = detailsProcessKey;
/* Items table drag and drop support */
var lastEnterTime
function dragStart(event) {
lastEnterTime = event.timeStamp
var row = event.currentTarget
row.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden")
})
event.dataTransfer.setData("text/plain", row.getAttribute("data-drag-path"))
var rowRectangle = row.getBoundingClientRect()
event.dataTransfer.setDragImage(row, event.x - rowRectangle.left, event.y - rowRectangle.top)
event.dataTransfer.dropEffect = "move"
}
window.dragStart = dragStart
/*
* Drag tracking assumptions (based on FF 122.0 experience):
* * Enter/Leave events at the same timeStamp may not be logically ordered
* (e.g. E -> E -> L, not E -> L -> E),
* * not every Enter event has corresponding Leave event, especially during
* rapid pointer moves
* NOTE: sometimes Leave is not emitted when pointer goes fast over table
* and outside. This should probably be fixed in browser, than patched here.
*/
function dragEnter(event) {
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id)
dragLeave(event)
lastEnterTime = event.timeStamp
const id = event.currentTarget.getAttribute("data-drop-id")
document.getElementById(id).classList.add("dropzone")
}
window.dragEnter = dragEnter
function dragOver(event) {
event.preventDefault()
}
window.dragOver = dragOver
function dragLeave(event) {
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id)
// Leave has been accounted for by Enter at the same timestamp, processed earlier
if (event.timeStamp <= lastEnterTime) return
event.currentTarget.closest("table").querySelectorAll(".dropzone").forEach((tr) => {
tr.classList.remove("dropzone")
})
}
window.dragLeave = dragLeave
function dragEnd(event) {
dragLeave(event)
event.currentTarget.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden")
})
}
window.dragEnd = dragEnd
function drop(event) {
event.preventDefault()
var idParam = event.currentTarget.getAttribute("data-drop-id-param")
var id = event.currentTarget.getAttribute("data-drop-id").split("_").pop()
var form = document.createElement('form');
form.action = event.dataTransfer.getData("text/plain");
form.method = 'post';
form.dataset.turboStream = 'true';
var input = document.createElement('input');
input.type = 'hidden'; input.name = idParam; input.value = id;
form.appendChild(input);
form.addEventListener('turbo:submit-end', function() { form.remove(); });
document.body.appendChild(form);
form.requestSubmit();
}
window.drop = drop

View File

@@ -0,0 +1,7 @@
import { Application } from "@hotwired/stimulus"
const application = Application.start()
application.debug = false
window.Stimulus = application
export { application }

View File

@@ -0,0 +1,45 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["container", "data"]
connect() {
const readouts = JSON.parse(this.dataTarget.textContent)
if (readouts.length === 0) return
const quantities = new Map()
readouts.forEach(r => {
if (!r.takenAt) return
if (!quantities.has(r.quantityId)) {
quantities.set(r.quantityId, { name: r.quantityName, unit: r.unit, x: [], y: [] })
}
const q = quantities.get(r.quantityId)
q.x.push(r.takenAt)
q.y.push(r.value)
})
const traces = []
quantities.forEach(q => {
traces.push({
x: q.x, y: q.y,
mode: 'lines+markers', type: 'scatter',
name: q.name + ' (' + q.unit + ')',
marker: { size: 5 }
})
})
const div = document.createElement('div')
div.className = 'chart-panel'
this.containerTarget.appendChild(div)
Plotly.newPlot(div, traces, {
xaxis: { type: 'date', tickformat: '%Y-%m-%d %H:%M' },
yaxis: {},
margin: { t: 20, r: 20, b: 80, l: 60 },
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
font: { family: 'system-ui' },
legend: { orientation: 'h', y: -0.25 }
}, { responsive: true, displayModeBar: false })
}
}

View File

@@ -0,0 +1,55 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["countLabel", "submitButton", "list"]
connect() {
this._observer = new MutationObserver(() => {
this.element.dispatchEvent(new Event('change', { bubbles: true }))
})
this._observer.observe(this.listTarget, { subtree: true, attributeFilter: ['disabled'] })
}
disconnect() {
this._observer?.disconnect()
}
change() {
const count = this.element.querySelectorAll('input:checked:not([disabled])').length
if (count > 0) {
this.countLabelTarget.textContent = count + ' selected'
Turbo.StreamElement.prototype.enableElement(this.submitButtonTarget)
} else {
this.countLabelTarget.textContent = this.countLabelTarget.dataset.prompt
Turbo.StreamElement.prototype.disableElement(this.submitButtonTarget)
}
}
close(event) {
if (!event.relatedTarget ||
event.relatedTarget.closest("details") != this.element) {
this.element.removeAttribute("open")
}
}
processKey(event) {
switch (event.key) {
case "Escape":
if (this.element.hasAttribute("open")) {
this.element.removeAttribute("open")
event.stopPropagation()
}
break
case "Enter": {
const button = this.element.querySelector("button:not([disabled])")
if (button) {
button.click()
event.target.blur()
event.preventDefault()
event.stopPropagation()
}
break
}
}
}
}

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()
}
}

View File

@@ -0,0 +1,25 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
processKey(event) {
switch (event.key) {
case "Escape":
this.element.querySelector("a[name=cancel]").click()
break
case "Enter":
this.element.querySelector("button[name=button]").click()
event.preventDefault()
break
}
}
validate(event) {
const id = event.submitter?.getAttribute("data-validate")
if (!id) return
const input = document.getElementById(id)
if (!input.checkValidity()) {
input.reportValidity()
event.preventDefault()
}
}
}

View File

@@ -0,0 +1,3 @@
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)

View File

@@ -0,0 +1,29 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["tbody"]
connect() {
this.#group()
this._observer = new MutationObserver(() => this.#group())
this._observer.observe(this.tbodyTarget, {
childList: true, subtree: true,
attributes: true, attributeFilter: ['style']
})
}
disconnect() {
this._observer?.disconnect()
}
#group() {
let prevTakenAt = null
Array.from(this.tbodyTarget.querySelectorAll('tr[data-taken-at]'))
.filter(row => row.style.display !== 'none')
.forEach(row => {
const takenAt = row.dataset.takenAt
row.classList.toggle('grouped', takenAt !== null && takenAt === prevTakenAt)
prevTakenAt = takenAt
})
}
}

View File

@@ -0,0 +1,17 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
document.body.dataset.measurementsView = this.#get()
}
set(event) {
const view = event.params.name
localStorage.setItem('measurements-view', view)
document.body.dataset.measurementsView = view
}
#get() {
return localStorage.getItem('measurements-view') || 'compact'
}
}

View File

@@ -0,0 +1,36 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["select", "button"]
unitChanged() {
if (this.selectTarget.value && this.selectTarget.value !== this.selectTarget.dataset.defaultUnitId) {
Turbo.StreamElement.prototype.enableElement(this.buttonTarget)
} else {
Turbo.StreamElement.prototype.disableElement(this.buttonTarget)
}
}
setDefault() {
const select = this.selectTarget
const form = document.createElement('form')
form.action = this.buttonTarget.dataset.path
form.method = 'post'
form.dataset.turboStream = 'true'
const methodInput = document.createElement('input')
methodInput.type = 'hidden'; methodInput.name = '_method'; methodInput.value = 'patch'
const unitInput = document.createElement('input')
unitInput.type = 'hidden'; unitInput.name = 'quantity[default_unit_id]'; unitInput.value = select.value
form.appendChild(methodInput)
form.appendChild(unitInput)
form.addEventListener('turbo:submit-end', event => {
if (event.detail.success) {
select.dataset.defaultUnitId = select.value
this.unitChanged()
}
form.remove()
})
document.body.appendChild(form)
form.requestSubmit()
}
}