Implement Measurements#new
This commit is contained in:
parent
2f3c0e40a6
commit
c48bf290fd
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -1,4 +1,6 @@
|
||||
class Readout < ApplicationRecord
|
||||
ATTRIBUTES = [:quantity_id, :value, :unit_id]
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :quantity
|
||||
belongs_to :unit
|
||||
|
@ -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
|
||||
|
||||
|
@ -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" %>
|
||||
|
45
app/views/measurements/_form.html.erb
Normal file
45
app/views/measurements/_form.html.erb
Normal 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(' '*(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 %>
|
2
app/views/measurements/_form_close.html.erb
Normal file
2
app/views/measurements/_form_close.html.erb
Normal file
@ -0,0 +1,2 @@
|
||||
<%= turbo_stream.update :new_readouts_form %>
|
||||
<%= turbo_stream.update :flashes %>
|
2
app/views/measurements/_form_destroy_row.html.erb
Normal file
2
app/views/measurements/_form_destroy_row.html.erb
Normal file
@ -0,0 +1,2 @@
|
||||
<%= turbo_stream.remove row %>
|
||||
<%= turbo_stream.update :flashes %>
|
@ -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('- '*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(' '*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>
|
||||
|
3
app/views/measurements/new.turbo_stream.erb
Normal file
3
app/views/measurements/new.turbo_stream.erb
Normal file
@ -0,0 +1,3 @@
|
||||
<%= turbo_stream.replace :new_readouts_form, method: :morph do %>
|
||||
<%= render partial: 'form' %>
|
||||
<% end %>
|
@ -7,7 +7,7 @@
|
||||
class: 'tools' %>
|
||||
</div>
|
||||
|
||||
<%= tag.div id: :quantity_form %>
|
||||
<%= tag.div class: 'main', id: :quantity_form %>
|
||||
|
||||
<table class="main items">
|
||||
<thead>
|
||||
|
@ -151,6 +151,7 @@ en:
|
||||
add: Add
|
||||
back: Back
|
||||
cancel: Cancel
|
||||
delete: Delete
|
||||
or: or
|
||||
register: Register
|
||||
sign_in: Sign in
|
||||
|
Loading…
x
Reference in New Issue
Block a user