Measurement form based on select-styled <details>

This commit is contained in:
2026-01-31 17:22:09 +01:00
parent 0fb7f9946a
commit bd1a664caa
29 changed files with 433 additions and 190 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z" /></svg>

After

Width:  |  Height:  |  Size: 148 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M14.06,9L15,9.94L5.92,19H5V18.08L14.06,9M17.66,3C17.41,3 17.15,3.1 16.96,3.29L15.13,5.12L18.88,8.87L20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18.17,3.09 17.92,3 17.66,3M14.06,6.19L3,17.25V21H6.75L17.81,9.94L14.06,6.19Z" /></svg>

After

Width:  |  Height:  |  Size: 316 B

View File

@@ -49,6 +49,7 @@
/* TODO: collapse gaps around empty rows (`topside`) once possible /* TODO: collapse gaps around empty rows (`topside`) once possible
* with grid-collapse property and remove alternative grid-template
* https://github.com/w3c/csswg-drafts/issues/5813 */ * https://github.com/w3c/csswg-drafts/issues/5813 */
body { body {
display: grid; display: grid;
@@ -56,20 +57,27 @@ body {
grid-template-areas: grid-template-areas:
"header header header" "header header header"
"nav nav nav" "nav nav nav"
"leftempty topside rightempty" "leftside topside rightside"
"leftside main rightside"; "leftside main rightside";
grid-template-columns: 1fr auto 1fr; grid-template-columns: 1fr minmax(max-content, 2fr) 1fr;
grid-template-rows: repeat(4, auto);
font-family: system-ui; font-family: system-ui;
margin: 0.4em; margin: 0.4em;
} }
body:not(:has(.topside)) {
grid-template-areas:
"header header header"
"nav nav nav"
"leftside main rightside";
}
button, button,
details,
input, input,
select, select,
textarea { textarea {
background-color: inherit; background-color: inherit;
font: inherit; font: inherit;
} }
details,
input, input,
select { select {
text-align: inherit; text-align: inherit;
@@ -86,9 +94,10 @@ input[type=submit] {
text-decoration: none; text-decoration: none;
white-space: nowrap; white-space: nowrap;
} }
/* [hidden] submit controls cannot have `display` set as it makes them visible */
.button, .button,
button, button:not([hidden]),
input[type=submit], input[type=submit]:not([hidden]),
.tab { .tab {
align-items: center; align-items: center;
color: var(--color-gray); color: var(--color-gray);
@@ -105,11 +114,13 @@ input[type=submit] {
} }
input:not([type=submit]):not([type=checkbox]), input:not([type=submit]):not([type=checkbox]),
select, select,
summary,
textarea { textarea {
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
} }
.button, .button,
button, button,
details,
input, input,
select, select,
textarea { textarea {
@@ -134,6 +145,9 @@ button > svg:not(:last-child) {
fieldset { fieldset {
padding: 0.4em; padding: 0.4em;
} }
fieldset:not(:has(input, select, textarea)) {
display: none;
}
legend { legend {
color: var(--color-gray); color: var(--color-gray);
display: flex; display: flex;
@@ -191,6 +205,7 @@ input::-webkit-outer-spin-button {
-webkit-appearance: none; -webkit-appearance: none;
margin: 0; margin: 0;
} }
details:hover,
input:hover, input:hover,
select:hover, select:hover,
textarea:hover { textarea:hover {
@@ -203,9 +218,11 @@ textarea:invalid {
border-color: var(--color-red); border-color: var(--color-red);
outline: solid 1px var(--color-red); outline: solid 1px var(--color-red);
} }
details:hover,
select:hover { select:hover {
cursor: pointer; cursor: pointer;
} }
details:focus-visible,
input:focus-visible, input:focus-visible,
select:focus-within, select:focus-within,
select:focus-visible, select:focus-visible,
@@ -417,10 +434,10 @@ table.items td.link a::after {
table.items td:first-child { table.items td:first-child {
padding-inline-start: calc(1em + var(--depth) * 0.8em); padding-inline-start: calc(1em + var(--depth) * 0.8em);
} }
table.items td:has(input, textarea) { table.items td:has(input, select, textarea) {
padding-inline-start: calc(0.6em - 0.9px); padding-inline-start: calc(0.6em - 0.9px);
} }
table.items td:first-child:has(input, textarea) { table.items td:first-child:has(input, select, textarea) {
padding-inline-start: calc(0.6em + var(--depth) * 0.8em - 0.9px); padding-inline-start: calc(0.6em + var(--depth) * 0.8em - 0.9px);
} }
table.items th:last-child { table.items th:last-child {
@@ -484,12 +501,6 @@ table.items td:not(:first-child),
color: var(--color-table-gray); color: var(--color-table-gray);
fill: var(--color-table-gray); fill: var(--color-table-gray);
} }
table.items td.hint {
color: var(--color-table-gray);
font-style: italic;
font-size: 0.8rem;
padding: 1em;
}
table.items svg { table.items svg {
height: 1.2rem; height: 1.2rem;
vertical-align: middle; vertical-align: middle;
@@ -555,16 +566,34 @@ form table.items td:first-child {
.extendedright { .extendedright {
margin-right: auto; margin-right: auto;
} }
.hexpand {
width: 100%;
}
.hflex { .hflex {
display: flex; display: flex;
gap: 0.8em; gap: 0.8em;
} }
.hflex.reverse {
flex-direction: row-reverse;
}
.hflex.centered {
justify-content: center;
}
.hint {
color: var(--color-table-gray);
font-style: italic;
font-size: 0.9rem;
text-align: center;
}
.vflex { .vflex {
display: flex; display: flex;
gap: 0.8em; gap: 0.8em;
flex-direction: column; flex-direction: column;
} }
[disabled] { [disabled] {
/* label:has(input[disabled]) {
* TODO: disabled checkbox blue square focus removal; disabled label styling
* */
border-color: var(--color-border-gray) !important; border-color: var(--color-border-gray) !important;
color: var(--color-border-gray) !important; color: var(--color-border-gray) !important;
cursor: not-allowed; cursor: not-allowed;
@@ -574,3 +603,71 @@ form table.items td:first-child {
.unwrappable { .unwrappable {
white-space: nowrap; white-space: nowrap;
} }
details {
align-content: center;
position: relative;
}
summary {
align-items: center;
color: var(--color-gray);
display: flex;
gap: 0.2em;
height: 100%;
white-space: nowrap;
}
summary::before {
background-color: #000;
content: "";
height: 1em;
mask-image: url('pictograms/chevron-down.svg');
mask-size: cover;
width: 1em;
}
summary:has(.button) {
padding-block: 0;
padding-inline-end: 0;
}
summary .button {
border: 1px var(--color-border-gray);
border-radius: 0;
border-style: none none none solid;
height: 100%;
}
summary span {
width: 100%;
}
details[open] summary::before {
transform: rotate(180deg);
}
summary::marker {
padding-left: 0.25em;
}
/* TODO: use ::details-content ? */
details[open] ul {
background: white;
border: solid 1px var(--color-border-gray);
border-radius: 0.25em;
box-sizing: content-box;
box-shadow: 1px 1px 3px var(--color-border-gray);
margin: 0;
margin-left: -0.9px;
padding-left: 0;
position: absolute;
width: 100%;
}
li:hover {
background-color: var(--color-focus-gray);
}
li label {
align-items: center;
display: flex;
line-height: 1.4em;
}
li input[type=checkbox] {
margin: 0 0.25em;
}
li::marker {
content: '';
}

View File

@@ -1,36 +1,11 @@
class MeasurementsController < ApplicationController class MeasurementsController < ApplicationController
before_action :find_quantity, only: [:new, :discard]
before_action :find_prev_quantities, only: [:new, :discard]
def index def index
@quantities = current_user.quantities.ordered @measurements = []
#@measurements = current_user.units.ordered.includes(:base, :subunits)
end end
def new def new
quantities = @quantities = current_user.quantities.ordered
case params[:scope]
when 'children'
@quantity.subquantities
when 'subtree'
@quantity.progenies
else
[@quantity]
end
quantities -= @prev_quantities
@readouts = current_user.readouts.build(quantities.map { |q| {quantity: q} })
@units = current_user.units.ordered
all_quantities = @prev_quantities + quantities
@common_ancestor = current_user.quantities
.common_ancestors(all_quantities.map(&:parent_id)).first
end
def discard
@prev_quantities -= [@quantity]
@common_ancestor = current_user.quantities
.common_ancestors(@prev_quantities.map(&:parent_id)).first
end end
def create def create
@@ -38,15 +13,4 @@ class MeasurementsController < ApplicationController
def destroy def destroy
end end
private
def find_quantity
@quantity = current_user.quantities.find_by!(id: params[:id])
end
def find_prev_quantities
prev_quantity_ids = params[:readouts]&.map { |r| r[:quantity_id] } || []
@prev_quantities = current_user.quantities.find(prev_quantity_ids)
end
end end

View File

@@ -0,0 +1,39 @@
class ReadoutsController < ApplicationController
before_action :find_quantities, only: [:new]
before_action :find_quantity, only: [:discard]
before_action :find_prev_quantities, only: [:new, :discard]
def new
@quantities -= @prev_quantities
# TODO: raise ParameterInvalid if new_quantities.empty?
@readouts = current_user.readouts.build(@quantities.map { |q| {quantity: q} })
@user_units = current_user.units.ordered
quantities = @prev_quantities + @quantities
@superquantity = current_user.quantities
.common_ancestors(quantities.map(&:parent_id)).first
end
def discard
@prev_quantities -= [@quantity]
@superquantity = current_user.quantities
.common_ancestors(@prev_quantities.map(&:parent_id)).first
end
private
def find_quantities
@quantities = current_user.quantities.find(params[:quantity])
end
def find_quantity
@quantity = current_user.quantities.find_by!(id: params[:id])
end
def find_prev_quantities
prev_quantity_ids = params[:readouts]&.map { |r| r[:quantity_id] } || []
@prev_quantities = current_user.quantities.find(prev_quantity_ids)
end
end

View File

@@ -75,12 +75,11 @@ module ApplicationHelper
end end
def number_field(method, options = {}) def number_field(method, options = {})
value = object.public_send(method) attr_type = object.type_for_attribute(method)
if value.is_a?(BigDecimal) if attr_type.type == :decimal
options[:value] = value.to_scientific options[:value] = object.public_send(method)&.to_scientific
type = object.class.type_for_attribute(method) options[:step] ||= BigDecimal(10).power(-attr_type.scale)
options[:step] ||= BigDecimal(10).power(-type.scale) options[:max] ||= BigDecimal(10).power(attr_type.precision - attr_type.scale) -
options[:max] ||= BigDecimal(10).power(type.precision - type.scale) -
options[:step] options[:step]
options[:min] = options[:min] == :step ? options[:step] : options[:min] options[:min] = options[:min] == :step ? options[:step] : options[:min]
options[:min] ||= -options[:max] options[:min] ||= -options[:max]

View File

@@ -1,2 +1,11 @@
module QuantitiesHelper module QuantitiesHelper
def quantities_check_boxes
# Closing <details> on focusout event depends on relatedTarget for internal
# actions being non-null. To ensure this, all top-layer elements of
# ::details-content must accept focus, e.g. <label> needs tabindex="-1" */
collection_check_boxes(nil, :quantity, @quantities, :id, :to_s_with_depth,
include_hidden: false) do |b|
content_tag :li, b.label(tabindex: -1) { b.check_box + b.text }
end
end
end end

View File

@@ -9,31 +9,70 @@ function showPage(event) {
} }
document.addEventListener('turbo:load', showPage) document.addEventListener('turbo:load', showPage)
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}))
});
/* Turbo stream actions */ /* Turbo stream actions */
Turbo.StreamElement.prototype.disableElement = function(element) {
element.setAttribute("disabled", "disabled")
element.setAttribute("aria-disabled", "true")
element.setAttribute("tabindex", "-1")
}
Turbo.StreamActions.disable = function() {
this.targetElements.forEach((e) => { this.disableElement(e) })
}
Turbo.StreamElement.prototype.enableElement = function(element) { Turbo.StreamElement.prototype.enableElement = function(element) {
element.removeAttribute("disabled") element.removeAttribute("disabled")
element.removeAttribute("aria-disabled") element.removeAttribute("aria-disabled")
// 'tabindex' is not used explicitly, so removing it is safe // Assume 'tabindex' is not used explicitly, so removing it is safe
element.removeAttribute("tabindex") element.removeAttribute("tabindex")
} }
Turbo.StreamActions.disable = function() {
this.targetElements.forEach((e) => {
e.setAttribute("disabled", "disabled")
e.setAttribute("aria-disabled", "true")
e.setAttribute("tabindex", "-1")
})
}
Turbo.StreamActions.enable = function() { Turbo.StreamActions.enable = function() {
this.targetElements.forEach((e) => { this.enableElement(e) }) this.targetElements.forEach((e) => { this.enableElement(e) })
} }
/* TODO: change to visibility = collapse to avoid width change? */
Turbo.StreamActions.hide = function() { Turbo.StreamActions.hide = function() {
this.targetElements.forEach((e) => { e.style.display = "none" }) this.targetElements.forEach((e) => { e.style.display = "none" })
} }
Turbo.StreamActions.show = function() {
this.targetElements.forEach((e) => { e.style.removeProperty("display") })
}
/*
Turbo.StreamActions.collapse = function() {
this.targetElements.forEach((e) => { e.style.visibility = "collapse" })
}
*/
Turbo.StreamActions.close_form = function() { Turbo.StreamActions.close_form = function() {
this.targetElements.forEach((e) => { this.targetElements.forEach((e) => {
/* Move focus if there's no focus or focus inside form being closed */ /* Move focus if there's no focus or focus inside form being closed */
@@ -54,28 +93,62 @@ Turbo.StreamActions.close_form = function() {
}) })
} }
Turbo.StreamActions.unselect = function() {
this.targetElements.forEach((e) => {
e.checked = false
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) {
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 */ /* Items table drag and drop support */
function processKey(event) { var lastEnterTime
if (event.key == "Escape") {
event.currentTarget.querySelector("a[name=cancel]").click();
}
}
window.processKey = processKey;
var lastEnterTime;
function dragStart(event) { function dragStart(event) {
lastEnterTime = event.timeStamp; lastEnterTime = event.timeStamp
var row = event.currentTarget; var row = event.currentTarget
row.closest("table").querySelectorAll("thead tr").forEach((tr) => { row.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden"); tr.toggleAttribute("hidden")
}); })
event.dataTransfer.setData("text/plain", row.getAttribute("data-drag-path")); event.dataTransfer.setData("text/plain", row.getAttribute("data-drag-path"))
var rowRectangle = row.getBoundingClientRect(); var rowRectangle = row.getBoundingClientRect()
event.dataTransfer.setDragImage(row, event.x - rowRectangle.left, event.y - rowRectangle.top); event.dataTransfer.setDragImage(row, event.x - rowRectangle.left, event.y - rowRectangle.top)
event.dataTransfer.dropEffect = "move"; event.dataTransfer.dropEffect = "move"
} }
window.dragStart = dragStart; window.dragStart = dragStart
/* /*
* Drag tracking assumptions (based on FF 122.0 experience): * Drag tracking assumptions (based on FF 122.0 experience):
@@ -87,44 +160,44 @@ window.dragStart = dragStart;
* and outside. This should probably be fixed in browser, than patched here. * and outside. This should probably be fixed in browser, than patched here.
*/ */
function dragEnter(event) { function dragEnter(event) {
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id); //console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id)
dragLeave(event); dragLeave(event)
lastEnterTime = event.timeStamp; lastEnterTime = event.timeStamp
const id = event.currentTarget.getAttribute("data-drop-id"); const id = event.currentTarget.getAttribute("data-drop-id")
document.getElementById(id).classList.add("dropzone"); document.getElementById(id).classList.add("dropzone")
} }
window.dragEnter = dragEnter; window.dragEnter = dragEnter
function dragOver(event) { function dragOver(event) {
event.preventDefault(); event.preventDefault()
} }
window.dragOver = dragOver; window.dragOver = dragOver
function dragLeave(event) { function dragLeave(event) {
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id); //console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id)
// Leave has been accounted for by Enter at the same timestamp, processed earlier // Leave has been accounted for by Enter at the same timestamp, processed earlier
if (event.timeStamp <= lastEnterTime) return; if (event.timeStamp <= lastEnterTime) return
event.currentTarget.closest("table").querySelectorAll(".dropzone").forEach((tr) => { event.currentTarget.closest("table").querySelectorAll(".dropzone").forEach((tr) => {
tr.classList.remove("dropzone"); tr.classList.remove("dropzone")
}); })
} }
window.dragLeave = dragLeave; window.dragLeave = dragLeave
function dragEnd(event) { function dragEnd(event) {
dragLeave(event); dragLeave(event)
event.currentTarget.closest("table").querySelectorAll("thead tr").forEach((tr) => { event.currentTarget.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden"); tr.toggleAttribute("hidden")
}); })
} }
window.dragEnd = dragEnd; window.dragEnd = dragEnd
function drop(event) { function drop(event) {
event.preventDefault(); event.preventDefault()
var params = new URLSearchParams(); var params = new URLSearchParams()
var id_param = event.currentTarget.getAttribute("data-drop-id-param"); var id_param = event.currentTarget.getAttribute("data-drop-id-param")
var id = event.currentTarget.getAttribute("data-drop-id").split("_").pop(); var id = event.currentTarget.getAttribute("data-drop-id").split("_").pop()
params.append(id_param, id); params.append(id_param, id)
fetch(event.dataTransfer.getData("text/plain"), { fetch(event.dataTransfer.getData("text/plain"), {
body: params, body: params,
@@ -138,4 +211,4 @@ function drop(event) {
.then(response => response.text()) .then(response => response.text())
.then(html => Turbo.renderStreamMessage(html)) .then(html => Turbo.renderStreamMessage(html))
} }
window.drop = drop; window.drop = drop

View File

@@ -0,0 +1,3 @@
class Measurement
include ActiveModel::Model
end

View File

@@ -100,6 +100,11 @@ class Quantity < ApplicationRecord
name name
end end
def to_s_with_depth
# em space, U+2003
'' * depth + name
end
def destroyable? def destroyable?
subquantities.empty? subquantities.empty?
end end

View File

@@ -1,25 +1,37 @@
<% @readouts.each do |readout| %> <%= tabular_form_with model: Measurement.new, id: :measurement_form,
<%= tabular_fields_for 'readouts[]', readout do |form| %> class: 'topside vflex', html: {onkeydown: 'formProcessKey(event)'} do |form| %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout), <fieldset>
onkeydown: 'processKey(event)' do %> <table class="items centered">
<%= tag.td id: dom_id(readout.quantity, nil, :pathname) do %> <tbody id="readouts"></tbody>
<%= readout.quantity.relative_pathname(@common_ancestor) %> </table>
<%end%> </fieldset>
<td>
<%= form.number_field :value, required: true, autofocus: true, size: 10 %>
</td>
<td>
<%= form.select :unit_id, options_from_collection_for_select(
@units, :id, ->(u){ sanitize('&emsp;'*(u.base_id ? 1 : 0) + u.symbol) }
) %>
</td>
<td class="actions"> <div class="hflex">
<%= image_button_tag '', 'delete-outline', class: 'dangerous', <details id="quantity_select" class="hexpand" open
formaction: discard_new_measurement_path(readout.quantity), onkeydown="detailsProcessKey(event)">
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %> <summary autofocus>
<%= form.hidden_field :quantity_id %> <!-- TODO: Set content with CSS when span empty to avoid duplication -->
</td> <span data-prompt="<%= t('.select_quantity') %>">
<% end %> <%= t('.select_quantity') %>
<% end %> </span>
<%= image_button_tag t(:apply), "update", name: nil, disabled: true,
formaction: new_readout_path, formmethod: :get, formnovalidate: true,
data: {turbo_stream: true} %>
</summary>
<ul><%= quantities_check_boxes %></ul>
</details>
<%= form.button id: :create_measurement_button, disabled: true -%>
</div>
<div class="hflex reverse">
<%= image_link_to t(:cancel), "close-outline", measurements_path, name: :cancel,
class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</div>
<% end %> <% 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,2 +1,4 @@
<%= turbo_stream.update :new_readouts_form %>
<%= turbo_stream.update :flashes %> <%= turbo_stream.update :flashes %>
<%= turbo_stream.remove :measurement_form %>
<%= turbo_stream.show :no_items -%>
<%= turbo_stream.enable :new_measurement_link -%>

View File

@@ -1,16 +0,0 @@
<%= tabular_fields_for Readout.new do |form| %>
<fieldset>
<legend>
<%= tag.span id: :new_readouts_form_legend %>
<%= image_link_to '', "close-outline", measurements_path, name: :cancel,
class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</legend>
<table class="items centered">
<tbody id="readouts">
<tr id="new_readouts_actions">
<td colspan="4"><div class="actions centered"><%= form.button %></div></td>
</tr>
</tbody>
</table>
</fieldset>
<% end %>

View File

@@ -1,7 +0,0 @@
<%= turbo_stream.update(:new_readouts_form_legend) { @common_ancestor&.pathname } %>
<% @prev_quantities.each do |pq| %>
<%= turbo_stream.update dom_id(pq, nil, :pathname) do %>
<%= pq.relative_pathname(@common_ancestor) %>
<% end %>
<% end %>

View File

@@ -1,2 +0,0 @@
<%= turbo_stream.remove dom_id(@quantity, :new, :readout) %>
<%= render partial: 'form_repath' %>

View File

@@ -1,20 +1,14 @@
<div class="topside vflex">
<% if current_user.at_least(:active) %>
<%# TODO: show hint when no quantities/units defined %> <%# TODO: show hint when no quantities/units defined %>
<%= tabular_form_with url: new_measurement_path, <div class="rightside buttongrid">
html: {id: :new_readouts_form} do |f| %> <% if current_user.at_least(:active) %>
<% end %> <%= image_link_to t('.new_measurement'), 'plus-outline', new_measurement_path,
<div class="hflex"> id: :new_measurement_link, onclick: 'this.blur();',
<%= select_tag :id, options_from_collection_for_select( data: {turbo_stream: true} %>
@quantities, :id, ->(q){ sanitize('&emsp;' * q.depth + q.name) }
), form: :new_readouts_form %>
<% common_options = {form: :new_readouts_form, formmethod: :get,
formnovalidate: true, data: {turbo_stream: true}} %>
<%= image_button_tag t('.new_quantity'), 'plus-outline', **common_options -%>
<%= image_button_tag t('.new_children'), 'plus-multiple-outline',
formaction: new_measurement_path(:children), **common_options -%>
<%= image_button_tag t('.new_subtree'), 'plus-multiple-outline',
formaction: new_measurement_path(:subtree), **common_options -%>
</div>
<% end %> <% end %>
</div> </div>
<table class="main">
<tbody id="measurements">
<%= render(@measurements) || render_no_items %>
</tbody>
</table>

View File

@@ -1,9 +1,5 @@
<%= turbo_stream.update :new_readouts_form do %> <%= turbo_stream.disable :new_measurement_link -%>
<%= render partial: 'form_frame' %> <%= turbo_stream.hide :no_items -%>
<% end if @prev_quantities.empty? %> <%= turbo_stream.append_all 'body' do %>
<%= render partial: 'form_repath' %>
<%= turbo_stream.before :new_readouts_actions do %>
<%= render partial: 'form' %> <%= render partial: 'form' %>
<% end %> <% end %>

View File

@@ -1,5 +1,5 @@
<%= tabular_fields_for @quantity, form: form_tag do |form| %> <%= tabular_fields_for @quantity, form: form_tag do |form| %>
<%- tag.tr id: row, class: "form", onkeydown: "processKey(event)", <%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {link: link, form: form_tag, hidden_row: hidden_row} do %> data: {link: link, form: form_tag, hidden_row: hidden_row} do %>
<td style="--depth:<%= @quantity.depth %>"> <td style="--depth:<%= @quantity.depth %>">

View File

@@ -1,7 +1,8 @@
<div class="rightside buttongrid"> <div class="rightside buttongrid">
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<%= image_link_to t('.new_quantity'), 'plus-outline', new_quantity_path, <%= image_link_to t('.new_quantity'), 'plus-outline', new_quantity_path,
id: dom_id(Quantity, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %> id: dom_id(Quantity, :new, :link), onclick: 'this.blur();',
data: {turbo_stream: true} %>
<% end %> <% end %>
<%#= image_link_to t('.import_quantities'), 'download-outline', default_quantities_path, <%#= image_link_to t('.import_quantities'), 'download-outline', default_quantities_path,
class: 'tools' %> class: 'tools' %>

View File

@@ -0,0 +1,25 @@
<%# TODO: add readout reordering by dragging %>
<%= tabular_fields_for 'readouts[]', readout do |form| %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout) do %>
<td class="actions">
<%# TODO: change to _link_ after giving up displaying relative paths %>
<%= image_button_tag '', 'delete-outline', class: 'dangerous', name: nil,
formaction: discard_readouts_path(readout.quantity),
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>
</td>
<td>
<%= readout.quantity.relative_pathname(@superquantity) %>
</td>
<td>
<%= form.number_field :value, required: true,
size: readout.type_for_attribute(:value).precision / 2,
autofocus: readout_counter == 0 %>
</td>
<td>
<%= form.hidden_field :quantity_id %>
<%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) },
{prompt: t('.select_unit'), disabled: '', selected: ''}, required: true %>
</td>
<% end %>
<% end %>

View File

@@ -0,0 +1,7 @@
<%= turbo_stream.update(:measurement_form_legend) { @superquantity&.pathname } %>
<% @prev_quantities.each do |pq| %>
<%= turbo_stream.update dom_id(pq, nil, :pathname) do %>
<%= pq.relative_pathname(@superquantity) %>
<% end %>
<% end %>

View File

@@ -0,0 +1,4 @@
<%= turbo_stream.disable :create_measurement_button if @prev_quantities.one? %>
<%= turbo_stream.remove dom_id(@quantity, :new, :readout) %>
<%= render partial: 'form_repath' %>
<%= turbo_stream.unselect dom_id(@quantity) %>

View File

@@ -0,0 +1,8 @@
<% @readouts.each do |r| %>
<%= turbo_stream.disable dom_id(r.quantity) %>
<% end %>
<%= render partial: 'form_repath' %>
<%= turbo_stream.append :readouts do %>
<%= render partial: 'form', collection: @readouts, as: :readout %>
<% end %>
<%= turbo_stream.enable :create_measurement_button if @prev_quantities.empty? %>

View File

@@ -1,5 +1,5 @@
<%= tabular_fields_for @unit, form: form_tag do |form| %> <%= tabular_fields_for @unit, form: form_tag do |form| %>
<%- tag.tr id: row, class: "form", onkeydown: "processKey(event)", <%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {link: link, form: form_tag, hidden_row: hidden_row} do %> data: {link: link, form: form_tag, hidden_row: hidden_row} do %>
<td style="--depth:<%= @unit.base_id? ? 1 : 0 %>"> <td style="--depth:<%= @unit.base_id? ? 1 : 0 %>">

View File

@@ -1,3 +1,4 @@
require 'core_ext/array_delete_bang'
require 'core_ext/big_decimal_scientific_notation' require 'core_ext/big_decimal_scientific_notation'
ActiveSupport.on_load :action_dispatch_system_test_case do ActiveSupport.on_load :action_dispatch_system_test_case do

View File

@@ -19,7 +19,19 @@ ActiveSupport.on_load :turbo_streams_tag_builder do
action :hide, target, allow_inferred_rendering: false action :hide, target, allow_inferred_rendering: false
end end
def show(target)
action :show, target, allow_inferred_rendering: false
end
#def collapse(target)
# action :collapse, target, allow_inferred_rendering: false
#end
def close_form(target) def close_form(target)
action :close_form, target, allow_inferred_rendering: false action :close_form, target, allow_inferred_rendering: false
end end
def unselect(target)
action :unselect, target, allow_inferred_rendering: false
end
end end

View File

@@ -64,10 +64,14 @@ en:
source_code: Get code source_code: Get code
measurements: measurements:
navigation: Measurements navigation: Measurements
no_items: There are no measurements taken. You can Add some now.
form:
select_quantity: select the measured quantities...
index: index:
new_quantity: Selected new_measurement: Add measurement
new_children: Children readouts:
new_subtree: Subtree form:
select_unit: ...
quantities: quantities:
navigation: Quantities navigation: Quantities
no_items: There are no configured quantities. You can Add some or Import from defaults. no_items: There are no configured quantities. You can Add some or Import from defaults.
@@ -149,6 +153,7 @@ en:
other: (%{count} characters minimum) other: (%{count} characters minimum)
actions: Actions actions: Actions
add: Add add: Add
apply: Apply
back: Back back: Back
cancel: Cancel cancel: Cancel
delete: Delete delete: Delete

View File

@@ -1,8 +1,8 @@
Rails.application.routes.draw do Rails.application.routes.draw do
resources :measurements, path_names: {new: '/new(/:scope)'}, resources :measurements
constraints: {scope: /children|subtree/}, defaults: {scope: nil} do
get 'discard/:id', on: :new, action: :discard, as: :discard resources :readouts, only: [:new] do
collection {get 'new/:id/discard', action: :discard, as: :discard}
end end
resources :quantities, except: [:show], path_names: {new: '(/:id)/new'} do resources :quantities, except: [:show], path_names: {new: '(/:id)/new'} do

View File

@@ -0,0 +1,10 @@
module CoreExt
module ArrayDeleteBang
def delete!(obj)
self.delete(obj)
self
end
end
end
Array.prepend CoreExt::ArrayDeleteBang