diff --git a/Gemfile b/Gemfile index 75a875e..ee86856 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source "https://rubygems.org" ruby file: ".ruby-version" -gem "rails", "~> 7.1.2" +gem "rails", "~> 7.2.2" gem "sprockets-rails" gem "mysql2", "~> 0.5" gem "puma", "~> 6.0" diff --git a/Gemfile.lock b/Gemfile.lock index e6c06b6..49c2bbf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,124 +1,122 @@ GEM remote: https://rubygems.org/ specs: - actioncable (7.1.3) - actionpack (= 7.1.3) - activesupport (= 7.1.3) + actioncable (7.2.2) + actionpack (= 7.2.2) + activesupport (= 7.2.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.3) - actionpack (= 7.1.3) - activejob (= 7.1.3) - activerecord (= 7.1.3) - activestorage (= 7.1.3) - activesupport (= 7.1.3) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.1.3) - actionpack (= 7.1.3) - actionview (= 7.1.3) - activejob (= 7.1.3) - activesupport (= 7.1.3) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp + actionmailbox (7.2.2) + actionpack (= 7.2.2) + activejob (= 7.2.2) + activerecord (= 7.2.2) + activestorage (= 7.2.2) + activesupport (= 7.2.2) + mail (>= 2.8.0) + actionmailer (7.2.2) + actionpack (= 7.2.2) + actionview (= 7.2.2) + activejob (= 7.2.2) + activesupport (= 7.2.2) + mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.1.3) - actionview (= 7.1.3) - activesupport (= 7.1.3) + actionpack (7.2.2) + actionview (= 7.2.2) + activesupport (= 7.2.2) nokogiri (>= 1.8.5) racc - rack (>= 2.2.4) + rack (>= 2.2.4, < 3.2) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.3) - actionpack (= 7.1.3) - activerecord (= 7.1.3) - activestorage (= 7.1.3) - activesupport (= 7.1.3) + useragent (~> 0.16) + actiontext (7.2.2) + actionpack (= 7.2.2) + activerecord (= 7.2.2) + activestorage (= 7.2.2) + activesupport (= 7.2.2) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.3) - activesupport (= 7.1.3) + actionview (7.2.2) + activesupport (= 7.2.2) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.1.3) - activesupport (= 7.1.3) + activejob (7.2.2) + activesupport (= 7.2.2) globalid (>= 0.3.6) - activemodel (7.1.3) - activesupport (= 7.1.3) - activerecord (7.1.3) - activemodel (= 7.1.3) - activesupport (= 7.1.3) + activemodel (7.2.2) + activesupport (= 7.2.2) + activerecord (7.2.2) + activemodel (= 7.2.2) + activesupport (= 7.2.2) timeout (>= 0.4.0) - activestorage (7.1.3) - actionpack (= 7.1.3) - activejob (= 7.1.3) - activerecord (= 7.1.3) - activesupport (= 7.1.3) + activestorage (7.2.2) + actionpack (= 7.2.2) + activejob (= 7.2.2) + activerecord (= 7.2.2) + activesupport (= 7.2.2) marcel (~> 1.0) - activesupport (7.1.3) + activesupport (7.2.2) base64 + benchmark (>= 0.3) bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) - mutex_m - tzinfo (~> 2.0) - addressable (2.8.6) - public_suffix (>= 2.0.2, < 6.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) base64 (0.2.0) bcrypt (3.1.20) - bigdecimal (3.1.6) + benchmark (0.4.0) + bigdecimal (3.1.8) bindex (0.8.1) - builder (3.2.4) + builder (3.3.0) byebug (11.1.3) - capybara (3.39.2) + capybara (3.40.0) addressable matrix mini_mime (>= 0.1.3) - nokogiri (~> 1.8) + nokogiri (~> 1.11) rack (>= 1.6.0) rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - concurrent-ruby (1.2.3) + concurrent-ruby (1.3.4) connection_pool (2.4.1) crass (1.0.6) - date (3.3.4) - devise (4.9.3) + date (3.4.1) + devise (4.9.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) responders warden (~> 1.2.3) - drb (2.2.0) - ruby2_keywords - erubi (1.12.0) - ffi (1.16.3) + drb (2.2.1) + erubi (1.13.0) + ffi (1.17.0-x86_64-linux-gnu) globalid (1.2.1) activesupport (>= 6.1) - i18n (1.14.1) + i18n (1.14.6) concurrent-ruby (~> 1.0) - importmap-rails (2.0.1) + importmap-rails (2.0.3) actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) - io-console (0.7.2) - irb (1.11.1) - rdoc + io-console (0.8.0) + irb (1.14.1) + rdoc (>= 4.0.0) reline (>= 0.4.2) - loofah (2.22.0) + logger (1.6.2) + loofah (2.23.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -126,79 +124,77 @@ GEM net-imap net-pop net-smtp - marcel (1.0.2) + marcel (1.0.4) matrix (0.4.2) mini_mime (1.1.5) - minitest (5.21.2) - mutex_m (0.2.0) - mysql2 (0.5.5) - net-imap (0.4.9.1) + minitest (5.25.4) + mysql2 (0.5.6) + net-imap (0.5.1) date net-protocol net-pop (0.1.2) net-protocol net-protocol (0.2.2) timeout - net-smtp (0.4.0.1) + net-smtp (0.5.0) net-protocol - nio4r (2.7.0) - nokogiri (1.16.0-x86_64-linux) + nio4r (2.7.4) + nokogiri (1.16.8-x86_64-linux) racc (~> 1.4) orm_adapter (0.5.0) - psych (5.1.2) + psych (5.2.1) + date stringio - public_suffix (5.0.4) - puma (6.4.2) + public_suffix (6.0.1) + puma (6.5.0) nio4r (~> 2.0) - racc (1.7.3) - rack (3.0.8) + racc (1.8.1) + rack (3.1.8) rack-session (2.0.0) rack (>= 3.0.0) rack-test (2.1.0) rack (>= 1.3) - rackup (2.1.0) + rackup (2.2.1) rack (>= 3) - webrick (~> 1.8) - rails (7.1.3) - actioncable (= 7.1.3) - actionmailbox (= 7.1.3) - actionmailer (= 7.1.3) - actionpack (= 7.1.3) - actiontext (= 7.1.3) - actionview (= 7.1.3) - activejob (= 7.1.3) - activemodel (= 7.1.3) - activerecord (= 7.1.3) - activestorage (= 7.1.3) - activesupport (= 7.1.3) + rails (7.2.2) + actioncable (= 7.2.2) + actionmailbox (= 7.2.2) + actionmailer (= 7.2.2) + actionpack (= 7.2.2) + actiontext (= 7.2.2) + actionview (= 7.2.2) + activejob (= 7.2.2) + activemodel (= 7.2.2) + activerecord (= 7.2.2) + activestorage (= 7.2.2) + activesupport (= 7.2.2) bundler (>= 1.15.0) - railties (= 7.1.3) + railties (= 7.2.2) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.1) loofah (~> 2.21) - nokogiri (~> 1.14) - railties (7.1.3) - actionpack (= 7.1.3) - activesupport (= 7.1.3) - irb + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (7.2.2) + actionpack (= 7.2.2) + activesupport (= 7.2.2) + irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) zeitwerk (~> 2.6) - rake (13.1.0) - rdoc (6.6.2) + rake (13.2.1) + rdoc (6.8.1) psych (>= 4.0.0) - regexp_parser (2.9.0) - reline (0.4.2) + regexp_parser (2.9.3) + reline (0.5.12) io-console (~> 0.5) responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.2.6) - ruby2_keywords (0.0.5) + rexml (3.3.9) rubyzip (2.3.2) sassc (2.4.0) ffi (~> 1.9) @@ -208,27 +204,30 @@ GEM sprockets (> 3.0) sprockets-rails tilt - selenium-webdriver (4.16.0) + securerandom (0.4.0) + selenium-webdriver (4.27.0) + base64 (~> 0.2) + logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) - sprockets-rails (3.4.2) - actionpack (>= 5.2) - activesupport (>= 5.2) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) sprockets (>= 3.0.0) - stringio (3.1.0) - thor (1.3.0) - tilt (2.3.0) - timeout (0.4.1) - turbo-rails (2.0.0.pre.beta.3) + stringio (3.1.2) + thor (1.3.2) + tilt (2.4.0) + timeout (0.4.2) + turbo-rails (2.0.11) actionpack (>= 6.0.0) - activejob (>= 6.0.0) railties (>= 6.0.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + useragent (0.16.11) warden (1.2.9) rack (>= 2.0.9) web-console (4.2.1) @@ -236,14 +235,13 @@ GEM activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) - webrick (1.8.1) - websocket (1.2.10) + websocket (1.2.11) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.12) + zeitwerk (2.7.1) PLATFORMS x86_64-linux @@ -255,7 +253,7 @@ DEPENDENCIES importmap-rails mysql2 (~> 0.5) puma (~> 6.0) - rails (~> 7.1.2) + rails (~> 7.2.2) sassc-rails selenium-webdriver sprockets-rails @@ -263,5 +261,8 @@ DEPENDENCIES tzinfo-data web-console +RUBY VERSION + ruby 3.3.0p0 + BUNDLED WITH 2.5.3 diff --git a/README.md b/README.md index 2de7d16..d2420f5 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,11 @@ Software requirements * Server side: * Ruby version: developed on Ruby 3.x - * database with recursive Common Table Expressions (CTE) support, e.g. - MySQL >= 8.0, MariaDB >= 10.2.2 + * database with: + * recursive Common Table Expressions (CTE) support, e.g. + MySQL >= 8.0, MariaDB >= 10.2.2 + * decimal type with precision of at least 30 (not sure if SQLite3 + supports this) * for testing: browser as specified in _Client side_ requirements * Client side: * browser supporting below requirements (e.g. Firefox >= 121): @@ -118,3 +121,15 @@ Tests need to be run from within toplevel application directory: bundle exec rails test test/system/users_test.rb --seed 1234 + +### Icons + +Pictogrammers Material Design Icons: https://pictogrammers.com/library/mdi/ + + +### Rake tasks + +Exporting default settings defined in application to seed file (e.g. to send as +PR or share between installations): + + bundle exec rails db:seed:export diff --git a/app/assets/images/pictograms/close-outline.svg b/app/assets/images/pictograms/close-outline.svg new file mode 100644 index 0000000..46c536e --- /dev/null +++ b/app/assets/images/pictograms/close-outline.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/pictograms/download-multiple-outline.svg b/app/assets/images/pictograms/download-multiple-outline.svg new file mode 100644 index 0000000..24d0398 --- /dev/null +++ b/app/assets/images/pictograms/download-multiple-outline.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/pictograms/download-outline.svg b/app/assets/images/pictograms/download-outline.svg new file mode 100644 index 0000000..71466c0 --- /dev/null +++ b/app/assets/images/pictograms/download-outline.svg @@ -0,0 +1 @@ + diff --git a/app/assets/images/pictograms/upload-outline.svg b/app/assets/images/pictograms/upload-outline.svg new file mode 100644 index 0000000..de60d1e --- /dev/null +++ b/app/assets/images/pictograms/upload-outline.svg @@ -0,0 +1 @@ + diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 4c49e38..797249b 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -98,16 +98,21 @@ input[type=submit] { width: fit-content; } input:not([type=submit]):not([type=checkbox]), -select { +select, +textarea { padding: 0.2em 0.4em; } .button, button, input, -select { +select, +textarea { border: solid 1px var(--color-gray); border-radius: 0.25em; } +textarea { + margin: 0 +} .button > svg, .tab > svg, button > svg { @@ -151,7 +156,8 @@ input[type=checkbox]:checked { -webkit-appearance: checkbox; } input:hover, -select:hover { +select:hover, +textarea:hover { border-color: #009ade; outline: solid 1px #009ade; } @@ -160,11 +166,13 @@ select:hover { } input:focus-visible, select:focus-within, -select:focus-visible { +select:focus-visible, +textarea:focus-visible { accent-color: #006c9b; background-color: var(--color-focus-gray); } -input[type=text]:read-only { +input[type=text]:read-only, +textarea:read-only { border: none; padding-left: 0; padding-right: 0; @@ -336,7 +344,7 @@ table.items th, table.items td { padding-inline: 1em 0; } -table.items td:has(input) { +table.items td:has(input, textarea) { padding-inline-start: calc(0.6em - 0.9px); } table.items th:last-child { @@ -367,7 +375,7 @@ table.items td.link a::after { table.items td.subunit { padding-inline-start: 1.8em; } -table.items td.subunit:has(input) { +table.items td.subunit:has(input, textarea) { padding-inline-start: calc(1.4em - 1px); } table.items td.actions { @@ -390,6 +398,9 @@ table.items tr.dropzone::after { table.items td.handle { cursor: move; } +table.items tr.form td { + vertical-align: top; +} /* TODO: replace :hover:focus-visible combos with proper LOVE stye order */ /* TODO: Update styling, including rem removal. */ @@ -410,7 +421,8 @@ table.items td.link a:hover:focus-visible { color: #006c9b; } -table.items td:not(:first-child) { +table.items td:not(:first-child), +.grayed { color: var(--color-table-gray); fill: var(--color-table-gray); } @@ -439,7 +451,8 @@ table.items input[type=submit] { table.items .button:not(:hover), table.items button:not(:hover), table.items input:not(:hover), -table.items select:not(:hover) { +table.items select:not(:hover), +table.items textarea:not(:hover) { border-color: var(--color-border-gray); } table.items .button:not(:hover), diff --git a/app/controllers/default/units_controller.rb b/app/controllers/default/units_controller.rb new file mode 100644 index 0000000..6ca2733 --- /dev/null +++ b/app/controllers/default/units_controller.rb @@ -0,0 +1,54 @@ +class Default::UnitsController < ApplicationController + navigation_tab :units + + before_action :find_unit, only: :export + before_action :find_unit_default, only: [:import, :destroy] + + before_action only: :import do + raise AccessForbidden unless current_user.at_least(:active) + end + before_action except: [:index, :import] do + raise AccessForbidden unless current_user.at_least(:admin) + end + + def index + @units = current_user.units.defaults_diff.includes(:base).ordered + end + + def import + @unit.port!(current_user) + flash.now[:notice] = t('.success', unit: @unit) + ensure + run_and_render :index + end + + #def import_all + # From defaults_diff return not only portability, but reason for not being + # portable: missing_base and nesting_too_deep. Add portable and + # missing_base, if possible in one query + #end + + def export + @unit.port!(nil) + flash.now[:notice] = t('.success', unit: @unit) + ensure + run_and_render :index + end + + def destroy + @unit.destroy! + flash.now[:notice] = t('.success', unit: @unit) + ensure + run_and_render :index + end + + private + + def find_unit + @unit = Unit.find_by!(id: params[:id], user: current_user) + end + + def find_unit_default + @unit = Unit.find_by!(id: params[:id], user: nil) + end +end diff --git a/app/controllers/units/defaults_controller.rb b/app/controllers/units/defaults_controller.rb deleted file mode 100644 index 9a635d3..0000000 --- a/app/controllers/units/defaults_controller.rb +++ /dev/null @@ -1,10 +0,0 @@ -class Units::DefaultsController < ApplicationController - navigation_tab :units - - before_action except: :index do - raise AccessForbidden unless current_user.at_least(:admin) - end - - def index - end -end diff --git a/app/controllers/units_controller.rb b/app/controllers/units_controller.rb index 800bfe8..87c67ea 100644 --- a/app/controllers/units_controller.rb +++ b/app/controllers/units_controller.rb @@ -1,5 +1,5 @@ class UnitsController < ApplicationController - before_action only: [:new] do + before_action only: :new do find_unit if params[:id].present? end before_action :find_unit, only: [:edit, :update, :rebase, :destroy] @@ -9,7 +9,7 @@ class UnitsController < ApplicationController end def index - @units = current_user.units.includes(:subunits) + @units = current_user.units.includes(:subunits).ordered end def new @@ -19,7 +19,7 @@ class UnitsController < ApplicationController def create @unit = current_user.units.new(unit_params) if @unit.save - flash.now[:notice] = t(".success") + flash.now[:notice] = t('.success', unit: @unit) run_and_render :index else render :new @@ -31,7 +31,7 @@ class UnitsController < ApplicationController def update if @unit.update(unit_params.except(:base_id)) - flash.now[:notice] = t(".success") + flash.now[:notice] = t('.success', unit: @unit) run_and_render :index else render :edit @@ -40,25 +40,28 @@ class UnitsController < ApplicationController def rebase permitted = params.require(:unit).permit(:base_id) - if permitted[:base_id].blank? && @unit.multiplier != 1 - permitted.merge!(multiplier: 1) - flash.now[:notice] = t(".multiplier_reset", symbol: @unit.symbol) - end + permitted.merge!(multiplier: 1) if permitted[:base_id].blank? && @unit.multiplier != 1 - run_and_render :index if @unit.update(permitted) + @unit.update!(permitted) + + if @unit.multiplier_previously_changed? + flash.now[:notice] = t(".multiplier_reset", unit: @unit) + end + ensure + run_and_render :index end def destroy - if @unit.destroy - flash.now[:notice] = t(".success") - end + @unit.destroy! + flash.now[:notice] = t('.success', unit: @unit) + ensure run_and_render :index end private def unit_params - params.require(:unit).permit(:symbol, :name, :base_id, :multiplier) + params.require(:unit).permit(Unit::ATTRIBUTES) end def find_unit diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 29f4cbb..4bf7ac4 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -2,12 +2,13 @@ class UsersController < ApplicationController helper_method :allow_disguise? before_action :find_user, only: [:show, :update, :disguise] - before_action except: :revert do - raise AccessForbidden unless current_user.at_least(:admin) - end + before_action only: :revert do raise AccessForbidden unless current_user_disguised? end + before_action except: :revert do + raise AccessForbidden unless current_user.at_least(:admin) + end def index @users = User.all diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 4007d3a..4acb234 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -84,6 +84,7 @@ module ApplicationHelper [:button_to, :link_to, :link_to_unless_current].each do |method_name| class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 def image_#{method_name}(name, image = nil, options = nil, html_options = {}, &block) + name = name.to_s name = svg_tag("pictograms/\#{image}") + name if image html_options[:class] = class_names( @@ -95,6 +96,11 @@ module ApplicationHelper html_options[:onclick] = "return confirm('\#{html_options[:onclick][:confirm]}');" end + if __method__.start_with?('image_link_to') && + !(html_options[:onclick] || html_options.dig(:data, :turbo_stream)) + name = name + '...' + end + send :#{method_name}, name, options, html_options, &block end RUBY_EVAL @@ -123,27 +129,13 @@ module ApplicationHelper "Turbo.renderStreamMessage('#{j(render partial: partial, locals: locals)}'); return false;" end - private + def disabled_attributes(disabled) + disabled ? {disabled: true, aria: {disabled: true}, tabindex: -1} : {} + end - # Converts value to HTML formatted scientific notation - def scientifize(d) - sign, coefficient, base, exponent = d.split - return 'NaN' unless sign - - result = (sign == -1 ? '-' : '') - unless coefficient == '1' && sign == 1 - if coefficient.length > 1 - result += coefficient.insert(1, '.') - elsif - result += coefficient - end - if exponent != 1 - result += "×" - end - end - if exponent != 1 - result += "10% d" % [exponent-1] - end - result.html_safe + def number_attributes(type) + step = BigDecimal(10).power(-type.scale) + max = BigDecimal(10).power(type.precision - type.scale) - step + {min: -max, max: max, step: step} end end diff --git a/app/helpers/default/units_helper.rb b/app/helpers/default/units_helper.rb new file mode 100644 index 0000000..ca7f755 --- /dev/null +++ b/app/helpers/default/units_helper.rb @@ -0,0 +1,2 @@ +module Default::UnitsHelper +end diff --git a/app/helpers/units/defaults_helper.rb b/app/helpers/units/defaults_helper.rb deleted file mode 100644 index 9721ef5..0000000 --- a/app/helpers/units/defaults_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module Units::DefaultsHelper -end diff --git a/app/models/unit.rb b/app/models/unit.rb index 6324fcb..c4445b2 100644 --- a/app/models/unit.rb +++ b/app/models/unit.rb @@ -1,26 +1,83 @@ class Unit < ApplicationRecord + ATTRIBUTES = [:symbol, :description, :multiplier, :base_id] + belongs_to :user, optional: true belongs_to :base, optional: true, class_name: "Unit" - has_many :subunits, class_name: "Unit", dependent: :restrict_with_error, inverse_of: :base + has_many :subunits, class_name: "Unit", inverse_of: :base, dependent: :restrict_with_error validate if: ->{ base.present? } do errors.add(:base, :user_mismatch) unless user == base.user errors.add(:base, :multilevel_nesting) if base.base.present? end validates :symbol, presence: true, uniqueness: {scope: :user_id}, - length: {maximum: columns_hash['symbol'].limit} - validates :name, length: {maximum: columns_hash['name'].limit} + length: {maximum: type_for_attribute(:symbol).limit} + validates :description, length: {maximum: type_for_attribute(:description).limit} validates :multiplier, numericality: {equal_to: 1}, unless: :base - validates :multiplier, numericality: {other_than: 0}, if: :base + validates :multiplier, numericality: {other_than: 0, precision: true, scale: true}, if: :base scope :defaults, ->{ where(user: nil) } + scope :defaults_diff, ->{ + actionable_units = Arel::Table.new('actionable_units') + units = actionable_units.alias('units') + bases_units = arel_table.alias('bases_units') + other_units = arel_table.alias('other_units') + other_bases_units = arel_table.alias('other_bases_units') + sub_units = arel_table.alias('sub_units') + + # TODO: move inner 'with' CTE to outer 'with recursive' - it can have multiple + # CTEs, even non recursive ones. + Unit.with_recursive(actionable_units: [ + Unit.with(units: self.or(Unit.defaults)).left_joins(:base) + .where.not( + # Exclude Units that are/have default counterpart + Arel::SelectManager.new.project(1).from(other_units) + .outer_join(other_bases_units) + .on(other_units[:base_id].eq(other_bases_units[:id])) + .where( + other_bases_units[:symbol].is_not_distinct_from(bases_units[:symbol]) + .and(other_units[:symbol].eq(arel_table[:symbol])) + .and(other_units[:user_id].is_distinct_from(arel_table[:user_id])) + ).exists + ) + .select( + arel_table[Arel.star], + # Decide if Unit can be im-/exported based on existing hierarchy: + # * same base unit symbol has to exist + # * unit with subunits can only be ported to root + arel_table[:base_id].eq(nil).or( + ( + Arel::SelectManager.new.project(1).from(other_units) + .join(sub_units).on(other_units[:id].eq(sub_units[:base_id])) + .where( + other_units[:symbol].eq(arel_table[:symbol]) + .and(other_units[:user_id].is_distinct_from(arel_table[:user_id])) + ).exists.not + ).and( + Arel::SelectManager.new.project(1).from(other_bases_units) + .where( + other_bases_units[:symbol].is_not_distinct_from(bases_units[:symbol]) + .and(other_bases_units[:user_id].is_distinct_from(bases_units[:user_id])) + ).exists + ) + ).as('portable') + ), + # Fill base Units to display proper hierarchy. Duplicates will be removed + # by final group() - can't be deduplicated with UNION due to 'portable' field. + arel_table.join(actionable_units).on(actionable_units[:base_id].eq(arel_table[:id])) + .project(arel_table[Arel.star], Arel::Nodes.build_quoted(nil).as('portable')) + ]).select(units: [:base_id, :symbol]) + .select( + units[:id].minimum.as('id'), # can be ANY_VALUE() + units[:user_id].minimum.as('user_id'), # prefer non-default + Arel::Nodes.build_quoted(1).as('multiplier'), # disregard multiplier when sorting + units[:portable].minimum.as('portable') + ) + .from(units).group(:base_id, :symbol) + } scope :ordered, ->{ - parent_symbol = Arel::Nodes::NamedFunction.new( - 'COALESCE', - [Arel::Table.new(:bases_units)[:symbol], arel_table[:symbol]] - ) left_outer_joins(:base) - .order(parent_symbol, arel_table[:base_id].asc.nulls_first, :multiplier, :symbol) + .order(arel_table.coalesce(Arel::Table.new(:bases_units)[:symbol], arel_table[:symbol]), + arel_table[:base_id].not_eq(nil), :multiplier, :symbol) } before_destroy do @@ -28,7 +85,23 @@ class Unit < ApplicationRecord nil end + def to_s + symbol + end + def movable? subunits.empty? end + + def default? + user_id.nil? + end + + # Should only by invoked on Units returned from #defaults_diff which are #portable + def port!(recipient) + recipient_base = base && Unit.find_by!(symbol: base.symbol, user: recipient) + params = slice(ATTRIBUTES - [:symbol, :base_id]) + Unit.find_or_initialize_by(user: recipient, symbol: symbol) + .update!(base: recipient_base, **params) + end end diff --git a/app/models/user.rb b/app/models/user.rb index 900d6ea..35f8c76 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -11,7 +11,15 @@ class User < ApplicationRecord disabled: 0, # administratively disallowed to sign in }, default: :active - has_many :units, -> { ordered }, dependent: :destroy + has_many :units, dependent: :destroy + + validates :email, presence: true, uniqueness: true, + length: {maximum: type_for_attribute(:email).limit} + validates :unconfirmed_email, length: {maximum: type_for_attribute(:unconfirmed_email).limit} + + def to_s + email + end def at_least(status) User.statuses[self.status] >= User.statuses[status] diff --git a/app/views/default/units/_unit.html.erb b/app/views/default/units/_unit.html.erb new file mode 100644 index 0000000..83e605b --- /dev/null +++ b/app/views/default/units/_unit.html.erb @@ -0,0 +1,23 @@ +<%= tag.tr do %> + + <%= unit %> + + + + <% unless unit.portable.nil? %> + <% if current_user.at_least(:active) && unit.default? %> + <%= image_button_to t('.import'), 'download-outline', import_default_unit_path(unit), + disabled_attributes(!unit.portable?) %> + <% end %> + <% if current_user.at_least(:admin) %> + <% if unit.default? %> + <%= image_button_to t('.delete'), 'delete-outline', default_unit_path(unit), + method: :delete %> + <% else %> + <%= image_button_to t('.export'), 'upload-outline', export_default_unit_path(unit), + disabled_attributes(!unit.portable?) %> + <% end %> + <% end %> + <% end %> + +<% end %> diff --git a/app/views/default/units/index.html.erb b/app/views/default/units/index.html.erb new file mode 100644 index 0000000..13c5a3e --- /dev/null +++ b/app/views/default/units/index.html.erb @@ -0,0 +1,22 @@ +
+ <% if current_user.at_least(:active) %> + <%# TODO: implement Import all %> + <%#= image_button_to t('.import_all'), 'download-multiple-outline', + import_all_default_units_path, data: {turbo_stream: true} %> + <% end %> + <%= image_link_to t('.back'), 'arrow-left-bold-outline', units_path, class: 'tools' %> +
+ + + + + + <% if current_user.at_least(:active) %> + + <% end %> + + + + <%= render(@units) || render_no_items %> + +
<%= User.human_attribute_name(:symbol).capitalize %><%= t '.actions' %>
diff --git a/app/views/default/units/index.turbo_stream.erb b/app/views/default/units/index.turbo_stream.erb new file mode 100644 index 0000000..2628fa7 --- /dev/null +++ b/app/views/default/units/index.turbo_stream.erb @@ -0,0 +1,3 @@ +<%= turbo_stream.update :units do %> + <%= render(@units) || render_no_items %> +<% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index ff65d54..f1b024a 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -29,7 +29,7 @@ <%= image_link_to t(".issue_tracker"), "bug-outline", issue_tracker_url, class: "extendedright" %> <% if user_signed_in? %> - <%= image_link_to_unless_current(current_user.email, "account-wrench-outline", + <%= image_link_to_unless_current(current_user, "account-wrench-outline", edit_user_registration_path) {} %> <% if current_user_disguised? %> <%= image_link_to t(".revert"), "incognito-off", revert_users_path %> diff --git a/app/views/units/_form.html.erb b/app/views/units/_form.html.erb index 249612c..f02f404 100644 --- a/app/views/units/_form.html.erb +++ b/app/views/units/_form.html.erb @@ -4,24 +4,26 @@ <%= form.text_field :symbol, form: :unit_form, required: true, autofocus: true, size: 12, - maxlength: @unit.class.columns_hash['symbol'].limit, autocomplete: "off" %> + maxlength: @unit.class.type_for_attribute(:symbol).limit, autocomplete: "off" %> - <%= form.text_field :name, form: :unit_form, size: 30, - maxlength: @unit.class.columns_hash['name'].limit, autocomplete: "off" %> + <%= form.text_area :description, form: :unit_form, cols: 30, rows: 1, escape: false, + maxlength: @unit.class.type_for_attribute(:description).limit, autocomplete: "off" %> <% unless @unit.base.nil? %> <%= form.hidden_field :base_id, form: :unit_form %> - <%= form.number_field :multiplier, form: :unit_form, required: true, step: "any", - size: 10, autocomplete: "off" %> + <%= form.number_field :multiplier, form: :unit_form, required: true, + size: 10, autocomplete: "off", + **number_attributes(@unit.class.type_for_attribute(:multiplier)) %> <% end %> <%= form.submit form: :unit_form %> - <%= image_link_to t(:cancel), "close-circle-outline", units_path, class: 'dangerous', + <%= image_link_to t(:cancel), "close-outline", units_path, class: 'dangerous', name: :cancel, onclick: render_turbo_stream('form_close', {link_id: link_id}) %> + <% end %> <% end %> diff --git a/app/views/units/_unit.html.erb b/app/views/units/_unit.html.erb index f970250..dda3faa 100644 --- a/app/views/units/_unit.html.erb +++ b/app/views/units/_unit.html.erb @@ -5,21 +5,21 @@ data: {drag_path: rebase_unit_path(unit), drop_id: dom_id(unit.base || unit)} do %> - <%= link_to unit.symbol, edit_unit_path(unit), id: dom_id(unit, :edit), + <%= link_to unit, edit_unit_path(unit), id: dom_id(unit, :edit), onclick: 'this.blur();', data: {turbo_stream: true} %> - <%= unit.name %> - <%= scientifize(unit.multiplier) %> + <%= unit.description %> + <%= unit.multiplier.to_html %> <% if current_user.at_least(:active) %> <% if unit.base.nil? %> - <%= image_link_to t(".add_subunit"), "plus-outline", new_unit_path(unit), + <%= image_link_to t('.add_subunit'), 'plus-outline', new_unit_path(unit), id: dom_id(unit, :add), onclick: 'this.blur();', data: {turbo_stream: true} %> <% end %> - <%= image_button_to t(".delete_unit"), "delete-outline", unit_path(unit), + <%= image_button_to t('.delete_unit'), 'delete-outline', unit_path(unit), method: :delete %> <% if unit.movable? %> diff --git a/app/views/units/defaults/index.html.erb b/app/views/units/defaults/index.html.erb deleted file mode 100644 index 34466c7..0000000 --- a/app/views/units/defaults/index.html.erb +++ /dev/null @@ -1,2 +0,0 @@ -

Units::Defaults#index

-

Find me in app/views/units/defaults/index.html.erb

diff --git a/app/views/units/index.html.erb b/app/views/units/index.html.erb index 19177c8..e2b8258 100644 --- a/app/views/units/index.html.erb +++ b/app/views/units/index.html.erb @@ -2,9 +2,8 @@ <% if current_user.at_least(:active) %> <%= image_link_to t('.add_unit'), 'plus-outline', new_unit_path, id: :add_unit, onclick: 'this.blur();', data: {turbo_stream: true} %> - <%= image_link_to t('.import_units'), 'import', new_unit_path, class: 'tools', - data: {turbo_stream: true} %> <% end %> + <%= image_link_to t('.import_units'), 'download-outline', default_units_path, class: 'tools' %> <%= tag.div id: :unit_form %> @@ -13,7 +12,7 @@ <%= User.human_attribute_name(:symbol).capitalize %> - <%= User.human_attribute_name(:name).capitalize %> + <%= User.human_attribute_name(:description).capitalize %> <%= User.human_attribute_name(:multiplier).capitalize %> <% if current_user.at_least(:active) %> <%= t :actions %> diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 18e8799..26c5502 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -11,7 +11,7 @@ <% @users.each do |user| %> - <%= link_to user.email, user_path(user) %> + <%= link_to user, user_path(user) %> <% if user == current_user %> <%= user.status %> diff --git a/app/views/users/mailer/password_change.html.erb b/app/views/users/mailer/password_change.html.erb index b41daf4..e750ca7 100644 --- a/app/views/users/mailer/password_change.html.erb +++ b/app/views/users/mailer/password_change.html.erb @@ -1,3 +1,3 @@ -

Hello <%= @resource.email %>!

+

Hello <%= @resource %>!

We're contacting you to notify you that your password has been changed.

diff --git a/app/views/users/mailer/reset_password_instructions.html.erb b/app/views/users/mailer/reset_password_instructions.html.erb index f667dc1..f1a48b5 100644 --- a/app/views/users/mailer/reset_password_instructions.html.erb +++ b/app/views/users/mailer/reset_password_instructions.html.erb @@ -1,4 +1,4 @@ -

Hello <%= @resource.email %>!

+

Hello <%= @resource %>!

Someone has requested a link to change your password. You can do this through the link below.

diff --git a/app/views/users/mailer/unlock_instructions.html.erb b/app/views/users/mailer/unlock_instructions.html.erb index 41e148b..0bf243d 100644 --- a/app/views/users/mailer/unlock_instructions.html.erb +++ b/app/views/users/mailer/unlock_instructions.html.erb @@ -1,4 +1,4 @@ -

Hello <%= @resource.email %>!

+

Hello <%= @resource %>!

Your account has been locked due to an excessive number of unsuccessful sign in attempts.

diff --git a/app/views/users/registrations/edit.html.erb b/app/views/users/registrations/edit.html.erb index 0a6aeef..28c323e 100644 --- a/app/views/users/registrations/edit.html.erb +++ b/app/views/users/registrations/edit.html.erb @@ -1,6 +1,6 @@ <% content_for :navigation, flush: true do %> - <%= image_link_to t(:back), 'arrow-left-bold-outline', - request.referer.present? ? :back : root_path %> + <%= link_to svg_tag("pictograms/arrow-left-bold-outline") + t(:back), + request.referer.present? ? :back : root_path, class: 'tab' %> <% end %>
diff --git a/config/application.rb.dist b/config/application.rb.dist index adce1d6..d28d914 100644 --- a/config/application.rb.dist +++ b/config/application.rb.dist @@ -23,6 +23,10 @@ module FixinMe # Initialize configuration defaults for originally generated Rails version. config.load_defaults 7.0 + # Autoload lib/, required e.g. for core library extensions. + # https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#config-autoload-lib-ignore. + config.autoload_lib(ignore: %w(assets tasks)) + # Configuration for the application, engines, and railties goes here. # # These settings can be overridden in specific environments using the files @@ -32,7 +36,7 @@ module FixinMe # config.eager_load_paths << Rails.root.join("extras") config.action_dispatch.rescue_responses['ApplicationController::AccessForbidden'] = :forbidden - config.action_dispatch.rescue_responses['ArgumentError'] = :bad_request + config.action_dispatch.rescue_responses['ApplicationController::ParameterInvalid'] = :unprocessable_entity # SETUP: Below settings need to be updated on a per-installation basis. # @@ -47,5 +51,8 @@ module FixinMe # Email address of admin account config.admin = 'admin@localhost' + + # Sender address of account registration-related messages + Devise.mailer_sender = 'noreply@localhost' end end diff --git a/config/initializers/core_ext.rb b/config/initializers/core_ext.rb new file mode 100644 index 0000000..fe6bd62 --- /dev/null +++ b/config/initializers/core_ext.rb @@ -0,0 +1,9 @@ +require 'core_ext/big_decimal_scientific_notation' + +ActiveSupport.on_load :active_record do + ActiveModel::Validations::NumericalityValidator.prepend CoreExt::ActiveModel::Validations::NumericalityValidatesPrecisionAndScale +end + +ActiveSupport.on_load :action_dispatch_system_test_case do + prepend CoreExt::ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelperUniqueId +end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index c7f707c..5012fdc 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -24,7 +24,8 @@ Devise.setup do |config| # Configure the e-mail address which will be shown in Devise::Mailer, # note that it will be overwritten if you use your own mailer class # with default "from" parameter. - config.mailer_sender = 'fixinme@noreply.me' + # This is set in 'config/application.rb'. + #config.mailer_sender = 'fixinme@noreply.me' # Configure the class responsible to send e-mails. # config.mailer = 'Devise::Mailer' diff --git a/config/initializers/system_test_case.rb b/config/initializers/system_test_case.rb deleted file mode 100644 index 9e3ef71..0000000 --- a/config/initializers/system_test_case.rb +++ /dev/null @@ -1,3 +0,0 @@ -ActiveSupport.on_load :action_dispatch_system_test_case do - prepend CoreExt::ActionDispatch::SystemTesting::TestHelpers::CustomScreenshotHelperUniqueId -end diff --git a/config/locales/en.yml b/config/locales/en.yml index 06f86ef..1fa61c1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,4 +1,8 @@ en: + errors: + messages: + precision_exceeded: must not exceed %{value} significant digits + scale_exceeded: must not exceed %{value} decimal digits activerecord: attributes: unit: @@ -33,6 +37,9 @@ en: forbidden: > You have not been granted access to this action (403 Forbidden). This should not happen, please notify site administrator. + not_found: > + The record that you requested operation on does not exist (404 Not Found). + This should not happen, please notify site administrator. unprocessable_entity: > The request is semantically incorrect and was rejected (422 Unprocessable Entity). This should not happen, please notify site administrator. @@ -54,22 +61,39 @@ en: delete_unit: Delete index: add_unit: Add unit - import_units: Import... - no_items: There are no configured units. You can try to import some defaults. + import_units: Import + no_items: There are no configured units. You can Add some or Import from defaults. top_level_drop: Drop here to reposition into top-level unit new: none: none create: - success: Created new unit + success: Created new unit "%{unit}" update: - success: Updated unit + success: Updated unit "%{unit}" rebase: - multiplier_reset: Multiplier of "%{symbol}" has been reset to 1, due to repositioning + multiplier_reset: Multiplier of "%{unit}" has been reset to 1, due to repositioning destroy: - success: Deleted unit + success: Deleted unit "%{unit}" + default: + units: + unit: + delete: Delete + export: Export + import: Import + index: + actions: Actions on defaults + back: Back to units + import_all: Import all + no_items: There are no differences between default and user units. + import: + success: Imported unit "%{unit}" + export: + success: Exported unit "%{unit}" + destroy: + success: Deleted unit "%{unit}" users: index: - disguise: View as... + disguise: View as passwords: edit: new_password: New password diff --git a/config/routes.rb b/config/routes.rb index 3f556c2..b62b98d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,22 +3,19 @@ Rails.application.routes.draw do controllers: {registrations: :registrations} resources :units, except: [:show], path_names: {new: '(/:id)/new'} do - member do - post :rebase - end + member { post :rebase } end - namespace :units do - get 'defaults/index' + namespace :default do + resources :units, only: [:index, :destroy] do + member { post :import, :export } + #collection { post :import_all } + end end resources :users, only: [:index, :show, :update] do - member do - get :disguise - end - collection do - get :revert - end + member { get :disguise } + collection { get :revert } end devise_scope :user do diff --git a/db/migrate/20230602185352_create_units.rb b/db/migrate/20230602185352_create_units.rb index 9a91f6d..4b5f64c 100644 --- a/db/migrate/20230602185352_create_units.rb +++ b/db/migrate/20230602185352_create_units.rb @@ -2,12 +2,12 @@ class CreateUnits < ActiveRecord::Migration[7.0] def change create_table :units do |t| t.references :user, foreign_key: true - t.string :symbol - t.string :name - t.decimal :multiplier, precision: 30, scale: 15, default: 1.0 + t.string :symbol, null: false, limit: 15 + t.text :description + t.decimal :multiplier, null: false, precision: 30, scale: 15, default: 1.0 t.references :base - t.timestamps + t.timestamps null: false end add_index :units, [:user_id, :symbol], unique: true end diff --git a/db/schema.rb b/db/schema.rb index 1dddba7..0006b01 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,12 +10,12 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2023_06_02_185352) do +ActiveRecord::Schema[7.2].define(version: 2023_06_02_185352) do create_table "units", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "user_id" - t.string "symbol" - t.string "name" - t.decimal "multiplier", precision: 30, scale: 15, default: "1.0" + t.string "symbol", limit: 15, null: false + t.text "description" + t.decimal "multiplier", precision: 30, scale: 15, default: "1.0", null: false t.bigint "base_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false diff --git a/db/seeds.rb b/db/seeds.rb index ff9e6e0..335a35f 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -20,19 +20,4 @@ end # Formulas will be deleted as dependent on Quantities #[Source, Quantity, Unit].each { |model| model.defaults.delete_all } -Unit.transaction do - Unit.defaults.delete_all - - unit_1 = Unit.create symbol: "1", name: "dimensionless, one" - Unit.create symbol: "%", base: unit_1, multiplier: 1e-2, name: "percent" - Unit.create symbol: "‰", base: unit_1, multiplier: 1e-3, name: "promille" - Unit.create symbol: "‱", base: unit_1, multiplier: 1e-4, name: "basis point" - Unit.create symbol: "ppm", base: unit_1, multiplier: 1e-6, name: "parts per million" - - unit_g = Unit.create symbol: "g", name: "gram" - Unit.create symbol: "ug", base: unit_g, multiplier: 1e-6, name: "microgram" - Unit.create symbol: "mg", base: unit_g, multiplier: 1e-3, name: "milligram" - Unit.create symbol: "kg", base: unit_g, multiplier: 1e3, name: "kilogram" - - Unit.create symbol: "kcal", name: "kilocalorie" -end +require 'seeds/units.rb' diff --git a/db/seeds/templates/units.erb b/db/seeds/templates/units.erb new file mode 100644 index 0000000..c2d4f88 --- /dev/null +++ b/db/seeds/templates/units.erb @@ -0,0 +1,9 @@ +Unit.transaction do + Unit.defaults.delete_all +<% Unit.defaults.ordered.each do |unit| %> +<%= "\n" if unit.base.nil? %> + unit_<%= unit.symbol %> = + Unit.create symbol: "<%= unit.symbol %>",<% unless unit.base.nil? %> base: unit_<%= unit.base.symbol %>, multiplier: <%= unit.multiplier.to_scientific %>,<% end %> + description: "<%= unit.description %>" +<% end %> +end diff --git a/db/seeds/units.rb b/db/seeds/units.rb new file mode 100644 index 0000000..1b79bf9 --- /dev/null +++ b/db/seeds/units.rb @@ -0,0 +1,36 @@ +Unit.transaction do + Unit.defaults.delete_all + + unit_1 = + Unit.create symbol: "1", + description: "dimensionless, one" + unit_ppm = + Unit.create symbol: "ppm", base: unit_1, multiplier: 1e-6, + description: "parts per million" + unit_‱ = + Unit.create symbol: "‱", base: unit_1, multiplier: 1e-4, + description: "basis point" + unit_‰ = + Unit.create symbol: "‰", base: unit_1, multiplier: 1e-3, + description: "promille" + unit_% = + Unit.create symbol: "%", base: unit_1, multiplier: 1e-2, + description: "percent" + + unit_g = + Unit.create symbol: "g", + description: "gram" + unit_ug = + Unit.create symbol: "ug", base: unit_g, multiplier: 1e-6, + description: "microgram" + unit_mg = + Unit.create symbol: "mg", base: unit_g, multiplier: 1e-3, + description: "milligram" + unit_kg = + Unit.create symbol: "kg", base: unit_g, multiplier: 1e3, + description: "kilogram" + + unit_kcal = + Unit.create symbol: "kcal", + description: "kilocalorie" +end diff --git a/lib/core_ext/action_dispatch/system_testing/test_helpers/custom_screenshot_helper_unique_id.rb b/lib/core_ext/action_dispatch/system_testing/test_helpers/screenshot_helper_unique_id.rb similarity index 52% rename from lib/core_ext/action_dispatch/system_testing/test_helpers/custom_screenshot_helper_unique_id.rb rename to lib/core_ext/action_dispatch/system_testing/test_helpers/screenshot_helper_unique_id.rb index 3798c38..248a6fd 100644 --- a/lib/core_ext/action_dispatch/system_testing/test_helpers/custom_screenshot_helper_unique_id.rb +++ b/lib/core_ext/action_dispatch/system_testing/test_helpers/screenshot_helper_unique_id.rb @@ -1,4 +1,4 @@ -module CoreExt::ActionDispatch::SystemTesting::TestHelpers::CustomScreenshotHelperUniqueId +module CoreExt::ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelperUniqueId private def unique diff --git a/lib/core_ext/active_model/validations/numericality_validates_precision_and_scale.rb b/lib/core_ext/active_model/validations/numericality_validates_precision_and_scale.rb new file mode 100644 index 0000000..a8fe744 --- /dev/null +++ b/lib/core_ext/active_model/validations/numericality_validates_precision_and_scale.rb @@ -0,0 +1,16 @@ +module CoreExt::ActiveModel::Validations::NumericalityValidatesPrecisionAndScale + def validate_each(record, attr_name, value, ...) + super(record, attr_name, value, ...) + + if options[:precision] || options[:scale] + attr_type = record.class.type_for_attribute(attr_name) + value = BigDecimal(value) unless value.is_a? BigDecimal + if options[:precision] && (value.precision > attr_type.precision) + record.errors.add(attr_name, :precision_exceeded, **filtered_options(attr_type.precision)) + end + if options[:scale] && (value.scale > attr_type.scale) + record.errors.add(attr_name, :scale_exceeded, **filtered_options(attr_type.scale)) + end + end + end +end diff --git a/lib/core_ext/big_decimal_scientific_notation.rb b/lib/core_ext/big_decimal_scientific_notation.rb new file mode 100644 index 0000000..0a45c90 --- /dev/null +++ b/lib/core_ext/big_decimal_scientific_notation.rb @@ -0,0 +1,36 @@ +module CoreExt + module BigDecimalScientificNotation + def to_scientific + return 'NaN' unless finite? + + sign, coefficient, base, exponent = split + (sign == -1 ? '-' : '') + + (coefficient.length > 1 ? coefficient.insert(1, '.') : coefficient) + + (exponent != 1 ? "e#{exponent-1}" : '') + end + + # Converts value to HTML formatted scientific notation + def to_html + sign, coefficient, base, exponent = split + return 'NaN' unless sign + + result = (sign == -1 ? '-' : '') + unless coefficient == '1' && sign == 1 + if coefficient.length > 1 + result += coefficient.insert(1, '.') + elsif + result += coefficient + end + if exponent != 1 + result += "×" + end + end + if exponent != 1 + result += "10% d" % [exponent-1] + end + result.html_safe + end + end +end + +BigDecimal.prepend CoreExt::BigDecimalScientificNotation diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake new file mode 100644 index 0000000..f2e5233 --- /dev/null +++ b/lib/tasks/db.rake @@ -0,0 +1,12 @@ +namespace :db do + namespace :seed do + desc "Dump default settings as seed data to db/seeds/*.rb" + task export: :environment do + seeds_path = Pathname.new(Rails.application.paths["db"].first) / 'seeds' + (seeds_path / 'templates').glob('*.erb').each do |template_path| + template = ERB.new(template_path.read, trim_mode: '<>') + (seeds_path / "#{template_path.basename('.*').to_s}.rb").write(template.result) + end + end + end +end diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index fab452d..2f70892 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -1,10 +1,13 @@ require "test_helper" class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + extend ActionView::Helpers::TranslationHelper include ActionView::Helpers::UrlHelper # NOTE: geckodriver installed with Firefox, ignore incompatibility warning - Selenium::WebDriver.logger.ignore(:selenium_manager) + Selenium::WebDriver.logger + .ignore(:selenium_manager, :clear_session_storage, :clear_local_storage) + Capybara.configure do |config| config.save_path = "#{Rails.root}/tmp/screenshots/" end @@ -30,4 +33,18 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase #def assert_stale(element) # assert_raises(Selenium::WebDriver::Error::StaleElementReferenceError) { element.tag_name } #end + + # HTML does not allow [disabled] attribute on tag, so it's not possible to + # easily find them using e.g. :link selector + #Capybara.add_selector(:disabled_link) do + # label " tag with [disabled] attribute" + #end + + test "click disabled link" do + # Link should be unclickable + # assert_raises(Selenium::WebDriver::Error::ElementClickInterceptedError) do + # # Use custom selector for disabled links + # find('a[disabled]').click + # end + end end diff --git a/test/controllers/units/defaults_controller_test.rb b/test/controllers/default/units_controller_test.rb similarity index 63% rename from test/controllers/units/defaults_controller_test.rb rename to test/controllers/default/units_controller_test.rb index b01dfe2..2742778 100644 --- a/test/controllers/units/defaults_controller_test.rb +++ b/test/controllers/default/units_controller_test.rb @@ -1,6 +1,6 @@ require "test_helper" -class Units::DefaultsControllerTest < ActionDispatch::IntegrationTest +class Default::UnitsControllerTest < ActionDispatch::IntegrationTest test "should get index" do get units_defaults_index_url assert_response :success diff --git a/test/fixtures/units.yml b/test/fixtures/units.yml index d580608..6e6ca5b 100644 --- a/test/fixtures/units.yml +++ b/test/fixtures/units.yml @@ -1,40 +1,40 @@ g: user: admin symbol: g - name: gram + description: gram kg: user: admin symbol: kg - name: kilogram + description: kilogram multiplier: 1000 base: g 1: user: admin symbol: 1 - name: one + description: one s: user: admin symbol: s - name: second + description: second percent: user: admin symbol: '%' - name: percent + description: percent multiplier: 0.01 base: 1 µg: user: admin symbol: µg - name: microgram + description: microgram multiplier: 0.000001 base: g mg: user: admin symbol: mg - name: milligram + description: milligram multiplier: 0.001 base: g g_alice: user: alice symbol: g - name: gram + description: gram diff --git a/test/system/units_test.rb b/test/system/units_test.rb index c96127e..cbdd625 100644 --- a/test/system/units_test.rb +++ b/test/system/units_test.rb @@ -1,8 +1,16 @@ require "application_system_test_case" class UnitsTest < ApplicationSystemTestCase + LINK_LABELS = { + add_unit: t('units.index.add_unit'), + add_subunit: t('units.unit.add_subunit'), + edit: nil + } + setup do @user = sign_in + LINK_LABELS[:edit] = Regexp.union(@user.units.map(&:symbol)) + visit units_path end @@ -12,7 +20,8 @@ class UnitsTest < ApplicationSystemTestCase assert_selector 'tr', count: @user.units.count end - Unit.destroy_all + # Cannot #destroy_all due to {dependent: :restrict*} on Unit.subunits association + @user.units.delete_all visit units_path within 'tbody' do assert_selector 'tr', count: 1 @@ -20,16 +29,24 @@ class UnitsTest < ApplicationSystemTestCase end end - test "add unit" do - click_on t('units.index.add_unit') + test "new" do + type, label = LINK_LABELS.assoc([:add_unit, :add_subunit].sample) + add_link = all(:link, exact_text: label).sample + add_link.click + assert_equal 'disabled', add_link[:disabled] within 'tbody > tr:has(input[type=text], textarea)' do assert_selector ':focus' - maxlength = all(:fillable_field).to_h { |f| [f[:name], f[:maxlength].to_i || 1000] } + + maxlength = all(:fillable_field).to_h { |f| [f[:name], f[:maxlength].to_i || 2**16] } + fill_in 'unit[symbol]', - with: SecureRandom.random_symbol(rand([1..15, 15..maxlength['unit[symbol]']].sample)) - fill_in 'unit[name]', - with: [nil, SecureRandom.alphanumeric(rand(1..maxlength['unit[name]']))].sample + with: random_string(rand([1..3, 4..maxlength['unit[symbol]']].sample)) + fill_in 'unit[description]', with: random_string(rand(0..maxlength['unit[description]'])) + within :field, 'unit[multiplier]' do |field| + fill_in with: random_number(field[:max], field[:step]) + end if add_link[:text] != t('units.index.add_unit') + assert_difference ->{ Unit.count }, 1 do click_on t('helpers.submit.create') end @@ -39,39 +56,48 @@ class UnitsTest < ApplicationSystemTestCase assert_no_selector :fillable_field assert_selector 'tr', count: @user.units.count end - assert_selector '.flash.notice', text: /^#{t('units.create.success')}/ + assert_no_selector :element, :a, 'disabled': 'disabled', + exact_text: Regexp.union(LINK_LABELS.fetch_values(:add_unit, :add_subunit)) + assert_selector '.flash.notice', text: t('units.create.success', unit: Unit.all.last.symbol) end - test "add and edit disallow opening multiple forms" do - # Once new/edit form is open, other actions on the same page either replace - # the form or leave it untouched - # TODO: add non-empty form closing warning + # TODO: check proper form/button redisplay and flash messages on add/edit + test "new and edit form on validation error" do + end + + # TODO: add non-empty form closing warning + test "new and edit disallow opening multiple forms" do + # Once new/edit form is open, attempt to open another one will close it links = {} - link_labels = {1 => [t('units.index.add_unit'), t('units.unit.add_subunit')], - 0 => units.map(&:symbol)} - link_labels.each_pair do |row_change, labels| - all(:link_or_button, exact_text: Regexp.union(labels)).map { |l| links[l] = row_change } + targets = {} + LINK_LABELS.each_pair do |type, labels| + links[type] = all(:link_or_button, exact_text: labels).to_a + targets[type] = links[type].sample end - link, rows = links.assoc(links.keys.sample).tap { |l, _| links.delete(l) } + # Define tr count change depending on link clicked + row_change = {add_unit: 1, add_subunit: 1, edit: 0} + + type, link = targets.assoc(targets.keys.sample).tap { |t, _| targets.delete(t) } + rows = row_change[type] assert_difference ->{ all('tbody tr').count }, rows do link.click end - find 'tbody tr:has(input[type=text]:focus)' - - # Link should be now unavailable or unclickable - begin - assert_raises(Selenium::WebDriver::Error::ElementClickInterceptedError) do - link.click - end if link.visible? - rescue Selenium::WebDriver::Error::StaleElementReferenceError - link = nil + within('tbody tr:has(input[type=text])') { assert_selector ':focus' } + if type == :edit + assert !link.visible? + [:add_subunit, :edit].each do |t| + assert_difference(->{ links[t].length }, -1) { links[t].select!(&:visible?) } + end + else + assert link[:disabled] end - link = links.keys.select(&:visible?).sample - assert_difference ->{ all('tbody tr').count }, links[link] - rows do + targets.merge([:add_subunit, :edit].map { |t| [t, links[t].sample] }.to_h) + type, link = targets.assoc(targets.keys.sample) + assert_difference ->{ all('tbody tr').count }, row_change[type] - rows do link.click end - assert_selector 'tbody tr:has(input[type=text]:focus)', count: 1 + within('tbody tr:has(input[type=text])') { assert_selector ':focus' } end # NOTE: extend with any add/edit link diff --git a/test/test_helper.rb b/test/test_helper.rb index 1e7f8dc..183e6e8 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -9,15 +9,41 @@ class ActiveSupport::TestCase # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all - include AbstractController::Translation include ActionMailer::TestHelper + include ActionView::Helpers::TranslationHelper - # NOTE: use public #alphanumeric(chars: ...) from Ruby 3.3 onwards - SecureRandom.class_eval do - def self.random_symbol(n = 10) - # Unicode characters: 32-126, 160-383 - choose([*' '..'~', 160.chr(Encoding::UTF_8), *'¡'..'ſ'], n) + # List of categorized Unicode characters: + # * http://www.unicode.org/Public/UNIDATA/UnicodeData.txt + # File format: http://www.unicode.org/L2/L1999/UnicodeData.html + # Select from graphic ranges: L, M, N, P, S, Zs + UNICODE_CHARS = { + 1 => [*"\u0020".."\u007E"], + 2 => [*"\u00A0".."\u00AC", + *"\u00AE".."\u05FF", + *"\u0606".."\u061B", + *"\u061D".."\u06DC", + *"\u06DE".."\u070E", + *"\u0710".."\u07FF"] + } + UNICODE_CHARS.default = UNICODE_CHARS[1] + UNICODE_CHARS[2] + def random_string(bytes = 10) + result = '' + while bytes > 0 + result += UNICODE_CHARS[bytes].sample.tap { |c| bytes -= c.bytesize } end + result + end + + # Assumes: max >= step and step = 1e[-]N, both as strings + def random_number(max, step) + max.delete!('.') + precision = max.length + start = rand(precision) + 1 + d = (rand(max.to_i) + 1) % 10**start + length = rand([0, 1..4, 4..precision].sample) + d = d.truncate(-start + length) + d = 10**(start - length) if d.zero? + BigDecimal(step) * d end def randomize_user_password!(user) @@ -25,11 +51,11 @@ class ActiveSupport::TestCase end def random_password - SecureRandom.alphanumeric rand(Rails.configuration.devise.password_length) + Random.alphanumeric rand(Rails.configuration.devise.password_length) end def random_email - "%s@%s.%s" % (1..3).map { SecureRandom.alphanumeric(rand(1..20)) } + "%s@%s.%s" % (1..3).map { Random.alphanumeric(rand(1..20)) } end def with_last_email