Implement Measurements#new

This commit is contained in:
cryptogopher 2025-02-14 17:48:34 +01:00
parent 2f3c0e40a6
commit c48bf290fd
14 changed files with 175 additions and 26 deletions

View File

@ -49,15 +49,18 @@
}
/* TODO: collapse gaps around empty rows (`topside`) once possible
* https://github.com/w3c/csswg-drafts/issues/5813 */
body {
display: grid;
gap: 0.8em;
grid-template-areas:
"header header header"
"nav nav nav"
"leftside main rightside";
"header header header"
"nav nav nav"
"leftempty topside rightempty"
"leftside main rightside";
grid-template-columns: 1fr auto 1fr;
grid-template-rows: repeat(3, auto);
grid-template-rows: repeat(4, auto);
font-family: system-ui;
margin: 0.4em;
}
@ -108,12 +111,14 @@ textarea {
}
.button,
button,
fieldset,
input,
select,
textarea {
border: solid 1px var(--color-gray);
border-radius: 0.25em;
}
fieldset,
textarea {
margin: 0
}
@ -124,6 +129,12 @@ button > svg {
padding-right: 0.4em;
width: 1.8em;
}
fieldset {
padding: 0.4em;
}
legend {
color: var(--color-gray);
}
/* TODO: move normal non-button links (<a>:hover/:focus) styling here (i.e.
* page-wide, top-level) and remove from table.items - as the style should be
@ -230,6 +241,9 @@ header {
}
.topside {
grid-area: topside;
}
.leftside {
grid-area: leftside;
}
@ -409,8 +423,7 @@ table.items td {
height: 2.4em;
padding-block: 0.1em;
}
table.items td.actions {
align-items: center;
table.items .actions {
display: flex;
gap: 0.4em;
justify-content: end;
@ -505,6 +518,17 @@ table.items select:focus-visible {
color: black;
}
form table.items {
border: none;
}
form table.items td {
border: none;
text-align: left;
vertical-align: middle;
}
form table.items td:first-child {
color: inherit;
}
.centered {
margin: 0 auto;
@ -512,10 +536,15 @@ table.items select:focus-visible {
.extendedright {
margin-right: auto;
}
.htoolbox {
.hflex {
display: flex;
gap: 0.8em;
}
.vflex {
display: flex;
gap: 0.8em;
flex-direction: column;
}
[disabled] {
border-color: var(--color-border-gray) !important;
color: var(--color-border-gray) !important;

View File

@ -1,9 +1,31 @@
class MeasurementsController < ApplicationController
before_action :find_quantity, only: [:new]
def index
@quantities = current_user.quantities.ordered
end
def new
readouts_params = params.permit(user: [readouts_attributes: Readout::ATTRIBUTES])
build_attrs = readouts_params.dig(:user, :readouts_attributes)&.values
prev_readouts = build_attrs ? Array(current_user.readouts.build(build_attrs)) : []
quantities =
case params[:scope]
when 'children'
@quantity.subquantities
when 'subtree'
@quantity.progenies
else
[@quantity]
end
quantities -= prev_readouts.map(&:quantity)
new_readouts = current_user.readouts.build(quantities.map { |q| {quantity: q} })
@readouts = prev_readouts + new_readouts
@ancestor_fullname = current_user.quantities
.common_ancestors(@readouts.map { |r| r.quantity.parent_id }).first&.fullname || ''
@units = current_user.units.ordered
end
def create
@ -11,4 +33,10 @@ class MeasurementsController < ApplicationController
def destroy
end
private
def find_quantity
@quantity = current_user.quantities.find_by!(id: params[:id])
end
end

View File

@ -104,11 +104,12 @@ module ApplicationHelper
end
def tabular_fields_for(record_name, record_object = nil, options = {}, &block)
# skip_default_ids causes turbo to generate unique ID for element with [autofocus].
# Otherwise IDs are not unique when multiple forms are open and the first input gets focus.
# skip_default_ids causes turbo to generate unique ID for element with
# [autofocus]. Otherwise IDs are not unique when multiple forms are open
# and the first input gets focus.
record_object, options = nil, record_object if record_object.is_a? Hash
options.merge! builder: TabularFormBuilder, skip_default_ids: true
render_errors(record_name)
render_errors(record_object || record_name)
fields_for(record_name, record_object, **options, &block)
end
@ -169,8 +170,9 @@ module ApplicationHelper
link_to name, options, html_options
end
def render_errors(record)
flash.now[:alert] = record.errors.full_messages unless record.errors.empty?
def render_errors(records)
flash[:alert] ||= []
Array(records).each { |record| flash[:alert] += record.errors.full_messages }
end
def render_flash_messages

View File

@ -61,17 +61,20 @@ class Quantity < ApplicationRecord
scope :defaults, ->{ where(user: nil) }
# Return: ordered [sub]hierarchy
scope :ordered, ->(root: nil) {
scope :ordered, ->(root: nil, include_root: true) {
numbered = Arel::Table.new('numbered')
self.model.with(numbered: numbered(:parent_id, :name)).with_recursive(arel_table.name => [
numbered.project(
numbered[Arel.star],
numbered.cast(numbered[:child_number], 'BINARY').as('path'),
).where(numbered[root ? :id : :parent_id].eq(root)),
numbered.cast(numbered[:name], 'CHAR(512)').as('fullname')
).where(numbered[root && include_root ? :id : :parent_id].eq(root)),
numbered.project(
numbered[Arel.star],
arel_table[:path].concat(numbered[:child_number]),
arel_table[:fullname].concat(Arel::Nodes.build_quoted(' → '))
.concat(numbered[:name]),
).join(arel_table).on(numbered[:parent_id].eq(arel_table[:id]))
]).order(arel_table[:path])
}
@ -109,6 +112,23 @@ class Quantity < ApplicationRecord
parent_id.nil?
end
# Common ancestors, assuming node is a descendant of itself
scope :common_ancestors, ->(of) {
selected = Arel::Table.new('selected')
# Take unique IDs, so self can be called with parent nodes of collection to
# get common ancestors of collection _excluding_ nodes in collection.
uniq_of = of.uniq
model.with(selected: self).with_recursive(arel_table.name => [
selected.project(selected[Arel.star]).where(selected[:id].in(uniq_of)),
selected.project(selected[Arel.star])
.join(arel_table).on(selected[:id].eq(arel_table[:parent_id]))
]).select(arel_table[Arel.star])
.group(column_names)
.having(arel_table[:id].count.eq(uniq_of.size))
.order(arel_table[:depth].desc)
}
# Return: successive record in order of appearance; used for partial view reload
def successive
quantities = Quantity.arel_table
@ -124,6 +144,10 @@ class Quantity < ApplicationRecord
user.quantities.ordered(root: id).to_a
end
def progenies
user.quantities.ordered(root: id, include_root: false).to_a
end
# Return: record with ID `of` with its ancestors, sorted by `depth`
scope :with_ancestors, ->(of) {
selected = Arel::Table.new('selected')
@ -143,4 +167,9 @@ class Quantity < ApplicationRecord
def ancestor_of?(progeny)
user.quantities.with_ancestors(progeny.id).exists?(id)
end
def fullname
self['fullname'] ||
user.quantities.with_ancestors(id).order(:depth).map(&:name).join(' → ')
end
end

View File

@ -1,4 +1,6 @@
class Readout < ApplicationRecord
ATTRIBUTES = [:quantity_id, :value, :unit_id]
belongs_to :user
belongs_to :quantity
belongs_to :unit

View File

@ -11,6 +11,8 @@ class User < ApplicationRecord
disabled: 0, # administratively disallowed to sign in
}, default: :active
has_many :readouts, dependent: :destroy
accepts_nested_attributes_for :readouts
has_many :quantities, dependent: :destroy
has_many :units, dependent: :destroy

View File

@ -23,7 +23,7 @@
</head>
<body>
<header class="htoolbox">
<header class="hflex">
<%= image_link_to t(".source_code"), "code-braces", source_code_url %>
<%= image_link_to t(".issue_tracker"), "bug-outline", issue_tracker_url,
class: "extendedright" %>

View File

@ -0,0 +1,45 @@
<%= tabular_form_with model: current_user, html: {id: :new_readouts_form} do |user| %>
<% if @readouts&.present? %>
<fieldset>
<%= tag.legend @ancestor_fullname unless @ancestor_fullname.empty? %>
<table class="items centered">
<tbody id="readouts">
<%= user.fields_for :readouts, @readouts do |form| %>
<% row = dom_id(form.object.quantity, :new, :readout) %>
<%- tag.tr id: row, onkeydown: 'processKey(event)' do %>
<td>
<%= form.object.quantity.fullname.delete_prefix(@ancestor_fullname) %>
<%= form.hidden_field :quantity_id %>
</td>
<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">
<%= image_link_to t(:delete), 'delete-outline', quantities_path,
class: 'dangerous',
onclick: render_turbo_stream('form_destroy_row', {row: row}) %>
</td>
<% end %>
<% end %>
<tr id="new_readouts_actions">
<td></td>
<td></td>
<td><div class="actions"><%= user.button %></div></td>
<td><div class="actions">
<%= image_link_to t(:cancel), "close-outline", measurements_path,
class: 'dangerous', name: :cancel,
onclick: render_turbo_stream('form_close') %>
</div></td>
</tr>
</tbody>
</table>
</fieldset>
<% end %>
<% end %>

View File

@ -0,0 +1,2 @@
<%= turbo_stream.update :new_readouts_form %>
<%= turbo_stream.update :flashes %>

View File

@ -0,0 +1,2 @@
<%= turbo_stream.remove row %>
<%= turbo_stream.update :flashes %>

View File

@ -1,16 +1,20 @@
<div class="main">
<div class="topside vflex">
<% if current_user.at_least(:active) %>
<%= render partial: 'form' %>
<%# TODO: show hint when no quantities/units defined %>
<%= form_tag new_measurement_path, method: :get, class: "htoolbox",
data: {turbo_stream: true} do %>
<%= select_tag :id,
options_from_collection_for_select(@quantities, :id,
->(q) { sanitize('-&nbsp;'*q.depth + q.name) }) %>
<%= image_button_tag t('.new_quantity'), 'plus-outline', name: :scope -%>
<div class="hflex">
<%= select_tag :id, options_from_collection_for_select(
@quantities, :id, ->(q){ sanitize('&emsp;'*q.depth + q.name) }
), form: :new_readouts_form %>
<% common_options = {form: :new_readouts_form, formaction: new_measurement_path,
formmethod: :get, formnovalidate: true,
data: {turbo_stream: true}} %>
<%= image_button_tag t('.new_quantity'), 'plus-outline', name: :scope,
**common_options -%>
<%= image_button_tag t('.new_children'), 'plus-multiple-outline', name: :scope,
value: :children -%>
value: :children, **common_options -%>
<%= image_button_tag t('.new_subtree'), 'plus-multiple-outline', name: :scope,
value: :subtree -%>
<% end %>
value: :subtree, **common_options -%>
</div>
<% end %>
</div>

View File

@ -0,0 +1,3 @@
<%= turbo_stream.replace :new_readouts_form, method: :morph do %>
<%= render partial: 'form' %>
<% end %>

View File

@ -7,7 +7,7 @@
class: 'tools' %>
</div>
<%= tag.div id: :quantity_form %>
<%= tag.div class: 'main', id: :quantity_form %>
<table class="main items">
<thead>

View File

@ -151,6 +151,7 @@ en:
add: Add
back: Back
cancel: Cancel
delete: Delete
or: or
register: Register
sign_in: Sign in