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' %>
+
+
+
+
+
+ <%= User.human_attribute_name(:symbol).capitalize %> |
+ <% if current_user.at_least(:active) %>
+ <%= t '.actions' %> |
+ <% end %>
+
+
+
+ <%= render(@units) || render_no_items %>
+
+
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
|