diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index ef20958..77442e2 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -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 (: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; diff --git a/app/controllers/measurements_controller.rb b/app/controllers/measurements_controller.rb index 1980b22..0ef5763 100644 --- a/app/controllers/measurements_controller.rb +++ b/app/controllers/measurements_controller.rb @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index bdd8e8f..4d06158 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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 diff --git a/app/models/quantity.rb b/app/models/quantity.rb index ffc5086..604d3f5 100644 --- a/app/models/quantity.rb +++ b/app/models/quantity.rb @@ -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 diff --git a/app/models/readout.rb b/app/models/readout.rb index f8855be..f50037c 100644 --- a/app/models/readout.rb +++ b/app/models/readout.rb @@ -1,4 +1,6 @@ class Readout < ApplicationRecord + ATTRIBUTES = [:quantity_id, :value, :unit_id] + belongs_to :user belongs_to :quantity belongs_to :unit diff --git a/app/models/user.rb b/app/models/user.rb index 0ef2ba3..d76568b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 2e89934..762b5a5 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -23,7 +23,7 @@ -
+
<%= image_link_to t(".source_code"), "code-braces", source_code_url %> <%= image_link_to t(".issue_tracker"), "bug-outline", issue_tracker_url, class: "extendedright" %> diff --git a/app/views/measurements/_form.html.erb b/app/views/measurements/_form.html.erb new file mode 100644 index 0000000..dcd4ff0 --- /dev/null +++ b/app/views/measurements/_form.html.erb @@ -0,0 +1,45 @@ +<%= tabular_form_with model: current_user, html: {id: :new_readouts_form} do |user| %> + <% if @readouts&.present? %> +
+ <%= tag.legend @ancestor_fullname unless @ancestor_fullname.empty? %> + + + <%= user.fields_for :readouts, @readouts do |form| %> + <% row = dom_id(form.object.quantity, :new, :readout) %> + <%- tag.tr id: row, onkeydown: 'processKey(event)' do %> + + + + + + <% end %> + <% end %> + + + + + + + +
+ <%= form.object.quantity.fullname.delete_prefix(@ancestor_fullname) %> + <%= form.hidden_field :quantity_id %> + + <%= form.number_field :value, required: true, autofocus: true, + size: 10 %> + + <%= form.select :unit_id, options_from_collection_for_select( + @units, :id, ->(u){ sanitize(' '*(u.base_id ? 1 : 0) + u.symbol) } + ) %> + + <%= image_link_to t(:delete), 'delete-outline', quantities_path, + class: 'dangerous', + onclick: render_turbo_stream('form_destroy_row', {row: row}) %> +
<%= user.button %>
+ <%= image_link_to t(:cancel), "close-outline", measurements_path, + class: 'dangerous', name: :cancel, + onclick: render_turbo_stream('form_close') %> +
+
+ <% end %> +<% end %> diff --git a/app/views/measurements/_form_close.html.erb b/app/views/measurements/_form_close.html.erb new file mode 100644 index 0000000..a171e26 --- /dev/null +++ b/app/views/measurements/_form_close.html.erb @@ -0,0 +1,2 @@ +<%= turbo_stream.update :new_readouts_form %> +<%= turbo_stream.update :flashes %> diff --git a/app/views/measurements/_form_destroy_row.html.erb b/app/views/measurements/_form_destroy_row.html.erb new file mode 100644 index 0000000..f567f95 --- /dev/null +++ b/app/views/measurements/_form_destroy_row.html.erb @@ -0,0 +1,2 @@ +<%= turbo_stream.remove row %> +<%= turbo_stream.update :flashes %> diff --git a/app/views/measurements/index.html.erb b/app/views/measurements/index.html.erb index 165101b..9d5aec4 100644 --- a/app/views/measurements/index.html.erb +++ b/app/views/measurements/index.html.erb @@ -1,16 +1,20 @@ -
+
<% 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 -%> +
+ <%= 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 -%> +
<% end %>
diff --git a/app/views/measurements/new.turbo_stream.erb b/app/views/measurements/new.turbo_stream.erb new file mode 100644 index 0000000..4c3a55c --- /dev/null +++ b/app/views/measurements/new.turbo_stream.erb @@ -0,0 +1,3 @@ +<%= turbo_stream.replace :new_readouts_form, method: :morph do %> + <%= render partial: 'form' %> +<% end %> diff --git a/app/views/quantities/index.html.erb b/app/views/quantities/index.html.erb index 6b0d909..0ed1454 100644 --- a/app/views/quantities/index.html.erb +++ b/app/views/quantities/index.html.erb @@ -7,7 +7,7 @@ class: 'tools' %>
-<%= tag.div id: :quantity_form %> +<%= tag.div class: 'main', id: :quantity_form %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 7ad730d..bee1caa 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -151,6 +151,7 @@ en: add: Add back: Back cancel: Cancel + delete: Delete or: or register: Register sign_in: Sign in