Merging from main master to my repo master. #4

Closed
Karavel wants to merge 53 commits from fixin.me/fixin.me:master into master
50 changed files with 710 additions and 311 deletions

View File

@ -1,7 +1,7 @@
source "https://rubygems.org" source "https://rubygems.org"
ruby file: ".ruby-version" ruby file: ".ruby-version"
gem "rails", "~> 7.1.2" gem "rails", "~> 7.2.2"
gem "sprockets-rails" gem "sprockets-rails"
gem "mysql2", "~> 0.5" gem "mysql2", "~> 0.5"
gem "puma", "~> 6.0" gem "puma", "~> 6.0"

View File

@ -1,124 +1,122 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.1.3) actioncable (7.2.2)
actionpack (= 7.1.3) actionpack (= 7.2.2)
activesupport (= 7.1.3) activesupport (= 7.2.2)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (7.1.3) actionmailbox (7.2.2)
actionpack (= 7.1.3) actionpack (= 7.2.2)
activejob (= 7.1.3) activejob (= 7.2.2)
activerecord (= 7.1.3) activerecord (= 7.2.2)
activestorage (= 7.1.3) activestorage (= 7.2.2)
activesupport (= 7.1.3) activesupport (= 7.2.2)
mail (>= 2.7.1) mail (>= 2.8.0)
net-imap actionmailer (7.2.2)
net-pop actionpack (= 7.2.2)
net-smtp actionview (= 7.2.2)
actionmailer (7.1.3) activejob (= 7.2.2)
actionpack (= 7.1.3) activesupport (= 7.2.2)
actionview (= 7.1.3) mail (>= 2.8.0)
activejob (= 7.1.3)
activesupport (= 7.1.3)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (7.1.3) actionpack (7.2.2)
actionview (= 7.1.3) actionview (= 7.2.2)
activesupport (= 7.1.3) activesupport (= 7.2.2)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
racc racc
rack (>= 2.2.4) rack (>= 2.2.4, < 3.2)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
actiontext (7.1.3) useragent (~> 0.16)
actionpack (= 7.1.3) actiontext (7.2.2)
activerecord (= 7.1.3) actionpack (= 7.2.2)
activestorage (= 7.1.3) activerecord (= 7.2.2)
activesupport (= 7.1.3) activestorage (= 7.2.2)
activesupport (= 7.2.2)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.1.3) actionview (7.2.2)
activesupport (= 7.1.3) activesupport (= 7.2.2)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (7.1.3) activejob (7.2.2)
activesupport (= 7.1.3) activesupport (= 7.2.2)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.1.3) activemodel (7.2.2)
activesupport (= 7.1.3) activesupport (= 7.2.2)
activerecord (7.1.3) activerecord (7.2.2)
activemodel (= 7.1.3) activemodel (= 7.2.2)
activesupport (= 7.1.3) activesupport (= 7.2.2)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (7.1.3) activestorage (7.2.2)
actionpack (= 7.1.3) actionpack (= 7.2.2)
activejob (= 7.1.3) activejob (= 7.2.2)
activerecord (= 7.1.3) activerecord (= 7.2.2)
activesupport (= 7.1.3) activesupport (= 7.2.2)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (7.1.3) activesupport (7.2.2)
base64 base64
benchmark (>= 0.3)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5) connection_pool (>= 2.2.5)
drb drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
mutex_m securerandom (>= 0.3)
tzinfo (~> 2.0) tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.6) addressable (2.8.7)
public_suffix (>= 2.0.2, < 6.0) public_suffix (>= 2.0.2, < 7.0)
base64 (0.2.0) base64 (0.2.0)
bcrypt (3.1.20) bcrypt (3.1.20)
bigdecimal (3.1.6) benchmark (0.4.0)
bigdecimal (3.1.8)
bindex (0.8.1) bindex (0.8.1)
builder (3.2.4) builder (3.3.0)
byebug (11.1.3) byebug (11.1.3)
capybara (3.39.2) capybara (3.40.0)
addressable addressable
matrix matrix
mini_mime (>= 0.1.3) mini_mime (>= 0.1.3)
nokogiri (~> 1.8) nokogiri (~> 1.11)
rack (>= 1.6.0) rack (>= 1.6.0)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
concurrent-ruby (1.2.3) concurrent-ruby (1.3.4)
connection_pool (2.4.1) connection_pool (2.4.1)
crass (1.0.6) crass (1.0.6)
date (3.3.4) date (3.4.1)
devise (4.9.3) devise (4.9.4)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 4.1.0) railties (>= 4.1.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
drb (2.2.0) drb (2.2.1)
ruby2_keywords erubi (1.13.0)
erubi (1.12.0) ffi (1.17.0-x86_64-linux-gnu)
ffi (1.16.3)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
i18n (1.14.1) i18n (1.14.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
importmap-rails (2.0.1) importmap-rails (2.0.3)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
io-console (0.7.2) io-console (0.8.0)
irb (1.11.1) irb (1.14.1)
rdoc rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
loofah (2.22.0) logger (1.6.2)
loofah (2.23.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.8.1)
@ -126,79 +124,77 @@ GEM
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
marcel (1.0.2) marcel (1.0.4)
matrix (0.4.2) matrix (0.4.2)
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.21.2) minitest (5.25.4)
mutex_m (0.2.0) mysql2 (0.5.6)
mysql2 (0.5.5) net-imap (0.5.1)
net-imap (0.4.9.1)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
net-protocol net-protocol
net-protocol (0.2.2) net-protocol (0.2.2)
timeout timeout
net-smtp (0.4.0.1) net-smtp (0.5.0)
net-protocol net-protocol
nio4r (2.7.0) nio4r (2.7.4)
nokogiri (1.16.0-x86_64-linux) nokogiri (1.16.8-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
orm_adapter (0.5.0) orm_adapter (0.5.0)
psych (5.1.2) psych (5.2.1)
date
stringio stringio
public_suffix (5.0.4) public_suffix (6.0.1)
puma (6.4.2) puma (6.5.0)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.7.3) racc (1.8.1)
rack (3.0.8) rack (3.1.8)
rack-session (2.0.0) rack-session (2.0.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.1.0) rackup (2.2.1)
rack (>= 3) rack (>= 3)
webrick (~> 1.8) rails (7.2.2)
rails (7.1.3) actioncable (= 7.2.2)
actioncable (= 7.1.3) actionmailbox (= 7.2.2)
actionmailbox (= 7.1.3) actionmailer (= 7.2.2)
actionmailer (= 7.1.3) actionpack (= 7.2.2)
actionpack (= 7.1.3) actiontext (= 7.2.2)
actiontext (= 7.1.3) actionview (= 7.2.2)
actionview (= 7.1.3) activejob (= 7.2.2)
activejob (= 7.1.3) activemodel (= 7.2.2)
activemodel (= 7.1.3) activerecord (= 7.2.2)
activerecord (= 7.1.3) activestorage (= 7.2.2)
activestorage (= 7.1.3) activesupport (= 7.2.2)
activesupport (= 7.1.3)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.1.3) railties (= 7.2.2)
rails-dom-testing (2.2.0) rails-dom-testing (2.2.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0) rails-html-sanitizer (1.6.1)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (~> 1.14) 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.1.3) railties (7.2.2)
actionpack (= 7.1.3) actionpack (= 7.2.2)
activesupport (= 7.1.3) activesupport (= 7.2.2)
irb irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rake (13.1.0) rake (13.2.1)
rdoc (6.6.2) rdoc (6.8.1)
psych (>= 4.0.0) psych (>= 4.0.0)
regexp_parser (2.9.0) regexp_parser (2.9.3)
reline (0.4.2) reline (0.5.12)
io-console (~> 0.5) io-console (~> 0.5)
responders (3.1.1) responders (3.1.1)
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
rexml (3.2.6) rexml (3.3.9)
ruby2_keywords (0.0.5)
rubyzip (2.3.2) rubyzip (2.3.2)
sassc (2.4.0) sassc (2.4.0)
ffi (~> 1.9) ffi (~> 1.9)
@ -208,27 +204,30 @@ GEM
sprockets (> 3.0) sprockets (> 3.0)
sprockets-rails sprockets-rails
tilt 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) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0) rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0) websocket (~> 1.0)
sprockets (4.2.1) sprockets (4.2.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
rack (>= 2.2.4, < 4) rack (>= 2.2.4, < 4)
sprockets-rails (3.4.2) sprockets-rails (3.5.2)
actionpack (>= 5.2) actionpack (>= 6.1)
activesupport (>= 5.2) activesupport (>= 6.1)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
stringio (3.1.0) stringio (3.1.2)
thor (1.3.0) thor (1.3.2)
tilt (2.3.0) tilt (2.4.0)
timeout (0.4.1) timeout (0.4.2)
turbo-rails (2.0.0.pre.beta.3) turbo-rails (2.0.11)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activejob (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
useragent (0.16.11)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
web-console (4.2.1) web-console (4.2.1)
@ -236,14 +235,13 @@ GEM
activemodel (>= 6.0.0) activemodel (>= 6.0.0)
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 6.0.0)
webrick (1.8.1) websocket (1.2.11)
websocket (1.2.10)
websocket-driver (0.7.6) websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.6.12) zeitwerk (2.7.1)
PLATFORMS PLATFORMS
x86_64-linux x86_64-linux
@ -255,7 +253,7 @@ DEPENDENCIES
importmap-rails importmap-rails
mysql2 (~> 0.5) mysql2 (~> 0.5)
puma (~> 6.0) puma (~> 6.0)
rails (~> 7.1.2) rails (~> 7.2.2)
sassc-rails sassc-rails
selenium-webdriver selenium-webdriver
sprockets-rails sprockets-rails
@ -263,5 +261,8 @@ DEPENDENCIES
tzinfo-data tzinfo-data
web-console web-console
RUBY VERSION
ruby 3.3.0p0
BUNDLED WITH BUNDLED WITH
2.5.3 2.5.3

View File

@ -9,8 +9,11 @@ Software requirements
* Server side: * Server side:
* Ruby version: developed on Ruby 3.x * Ruby version: developed on Ruby 3.x
* database with recursive Common Table Expressions (CTE) support, e.g. * database with:
MySQL >= 8.0, MariaDB >= 10.2.2 * 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 * for testing: browser as specified in _Client side_ requirements
* Client side: * Client side:
* browser supporting below requirements (e.g. Firefox >= 121): * 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 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

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M3,16.74L7.76,12L3,7.26L7.26,3L12,7.76L16.74,3L21,7.26L16.24,12L21,16.74L16.74,21L12,16.24L7.26,21L3,16.74M12,13.41L16.74,18.16L18.16,16.74L13.41,12L18.16,7.26L16.74,5.84L12,10.59L7.26,5.84L5.84,7.26L10.59,12L5.84,16.74L7.26,18.16L12,13.41Z" /></svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M12 14L19 7H15V1H9V7H5L12 14M12 11.17L9.83 9H11V3H13V9H14.17L12 11.17M5 16V18H19V16H5M5 22V20H19V22H5Z" /></svg>

After

Width:  |  Height:  |  Size: 192 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M13,5V11H14.17L12,13.17L9.83,11H11V5H13M15,3H9V9H5L12,16L19,9H15V3M19,18H5V20H19V18Z" /></svg>

After

Width:  |  Height:  |  Size: 174 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M9,10V16H15V10H19L12,3L5,10H9M12,5.8L14.2,8H13V14H11V8H9.8L12,5.8M19,18H5V20H19V18Z" /></svg>

After

Width:  |  Height:  |  Size: 173 B

View File

@ -98,16 +98,21 @@ input[type=submit] {
width: fit-content; width: fit-content;
} }
input:not([type=submit]):not([type=checkbox]), input:not([type=submit]):not([type=checkbox]),
select { select,
textarea {
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
} }
.button, .button,
button, button,
input, input,
select { select,
textarea {
border: solid 1px var(--color-gray); border: solid 1px var(--color-gray);
border-radius: 0.25em; border-radius: 0.25em;
} }
textarea {
margin: 0
}
.button > svg, .button > svg,
.tab > svg, .tab > svg,
button > svg { button > svg {
@ -151,7 +156,8 @@ input[type=checkbox]:checked {
-webkit-appearance: checkbox; -webkit-appearance: checkbox;
} }
input:hover, input:hover,
select:hover { select:hover,
textarea:hover {
border-color: #009ade; border-color: #009ade;
outline: solid 1px #009ade; outline: solid 1px #009ade;
} }
@ -160,11 +166,13 @@ select:hover {
} }
input:focus-visible, input:focus-visible,
select:focus-within, select:focus-within,
select:focus-visible { select:focus-visible,
textarea:focus-visible {
accent-color: #006c9b; accent-color: #006c9b;
background-color: var(--color-focus-gray); background-color: var(--color-focus-gray);
} }
input[type=text]:read-only { input[type=text]:read-only,
textarea:read-only {
border: none; border: none;
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
@ -336,7 +344,7 @@ table.items th,
table.items td { table.items td {
padding-inline: 1em 0; padding-inline: 1em 0;
} }
table.items td:has(input) { table.items td:has(input, textarea) {
padding-inline-start: calc(0.6em - 0.9px); padding-inline-start: calc(0.6em - 0.9px);
} }
table.items th:last-child { table.items th:last-child {
@ -367,7 +375,7 @@ table.items td.link a::after {
table.items td.subunit { table.items td.subunit {
padding-inline-start: 1.8em; 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); padding-inline-start: calc(1.4em - 1px);
} }
table.items td.actions { table.items td.actions {
@ -390,6 +398,9 @@ table.items tr.dropzone::after {
table.items td.handle { table.items td.handle {
cursor: move; cursor: move;
} }
table.items tr.form td {
vertical-align: top;
}
/* TODO: replace :hover:focus-visible combos with proper LOVE stye order */ /* TODO: replace :hover:focus-visible combos with proper LOVE stye order */
/* TODO: Update styling, including rem removal. */ /* TODO: Update styling, including rem removal. */
@ -410,7 +421,8 @@ table.items td.link a:hover:focus-visible {
color: #006c9b; color: #006c9b;
} }
table.items td:not(:first-child) { table.items td:not(:first-child),
.grayed {
color: var(--color-table-gray); color: var(--color-table-gray);
fill: 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 button:not(:hover), table.items button:not(:hover),
table.items input: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); border-color: var(--color-border-gray);
} }
table.items .button:not(:hover), table.items .button:not(:hover),

View File

@ -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

View File

@ -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

View File

@ -1,5 +1,5 @@
class UnitsController < ApplicationController class UnitsController < ApplicationController
before_action only: [:new] do before_action only: :new do
find_unit if params[:id].present? find_unit if params[:id].present?
end end
before_action :find_unit, only: [:edit, :update, :rebase, :destroy] before_action :find_unit, only: [:edit, :update, :rebase, :destroy]
@ -9,7 +9,7 @@ class UnitsController < ApplicationController
end end
def index def index
@units = current_user.units.includes(:subunits) @units = current_user.units.includes(:subunits).ordered
end end
def new def new
@ -19,7 +19,7 @@ class UnitsController < ApplicationController
def create def create
@unit = current_user.units.new(unit_params) @unit = current_user.units.new(unit_params)
if @unit.save if @unit.save
flash.now[:notice] = t(".success") flash.now[:notice] = t('.success', unit: @unit)
run_and_render :index run_and_render :index
else else
render :new render :new
@ -31,7 +31,7 @@ class UnitsController < ApplicationController
def update def update
if @unit.update(unit_params.except(:base_id)) if @unit.update(unit_params.except(:base_id))
flash.now[:notice] = t(".success") flash.now[:notice] = t('.success', unit: @unit)
run_and_render :index run_and_render :index
else else
render :edit render :edit
@ -40,25 +40,28 @@ class UnitsController < ApplicationController
def rebase def rebase
permitted = params.require(:unit).permit(:base_id) permitted = params.require(:unit).permit(:base_id)
if permitted[:base_id].blank? && @unit.multiplier != 1 permitted.merge!(multiplier: 1) if permitted[:base_id].blank? && @unit.multiplier != 1
permitted.merge!(multiplier: 1)
flash.now[:notice] = t(".multiplier_reset", symbol: @unit.symbol)
end
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 end
def destroy def destroy
if @unit.destroy @unit.destroy!
flash.now[:notice] = t(".success") flash.now[:notice] = t('.success', unit: @unit)
end ensure
run_and_render :index run_and_render :index
end end
private private
def unit_params def unit_params
params.require(:unit).permit(:symbol, :name, :base_id, :multiplier) params.require(:unit).permit(Unit::ATTRIBUTES)
end end
def find_unit def find_unit

View File

@ -2,12 +2,13 @@ class UsersController < ApplicationController
helper_method :allow_disguise? helper_method :allow_disguise?
before_action :find_user, only: [:show, :update, :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 before_action only: :revert do
raise AccessForbidden unless current_user_disguised? raise AccessForbidden unless current_user_disguised?
end end
before_action except: :revert do
raise AccessForbidden unless current_user.at_least(:admin)
end
def index def index
@users = User.all @users = User.all

View File

@ -84,6 +84,7 @@ module ApplicationHelper
[:button_to, :link_to, :link_to_unless_current].each do |method_name| [:button_to, :link_to, :link_to_unless_current].each do |method_name|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def image_#{method_name}(name, image = nil, options = nil, html_options = {}, &block) def image_#{method_name}(name, image = nil, options = nil, html_options = {}, &block)
name = name.to_s
name = svg_tag("pictograms/\#{image}") + name if image name = svg_tag("pictograms/\#{image}") + name if image
html_options[:class] = class_names( html_options[:class] = class_names(
@ -95,6 +96,11 @@ module ApplicationHelper
html_options[:onclick] = "return confirm('\#{html_options[:onclick][:confirm]}');" html_options[:onclick] = "return confirm('\#{html_options[:onclick][:confirm]}');"
end 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 send :#{method_name}, name, options, html_options, &block
end end
RUBY_EVAL RUBY_EVAL
@ -123,27 +129,13 @@ module ApplicationHelper
"Turbo.renderStreamMessage('#{j(render partial: partial, locals: locals)}'); return false;" "Turbo.renderStreamMessage('#{j(render partial: partial, locals: locals)}'); return false;"
end end
private def disabled_attributes(disabled)
disabled ? {disabled: true, aria: {disabled: true}, tabindex: -1} : {}
end
# Converts value to HTML formatted scientific notation def number_attributes(type)
def scientifize(d) step = BigDecimal(10).power(-type.scale)
sign, coefficient, base, exponent = d.split max = BigDecimal(10).power(type.precision - type.scale) - step
return 'NaN' unless sign {min: -max, max: max, step: step}
result = (sign == -1 ? '-' : '')
unless coefficient == '1' && sign == 1
if coefficient.length > 1
result += coefficient.insert(1, '.')
elsif
result += coefficient
end
if exponent != 1
result += "&times;"
end
end
if exponent != 1
result += "10<sup>% d</sup>" % [exponent-1]
end
result.html_safe
end end
end end

View File

@ -0,0 +1,2 @@
module Default::UnitsHelper
end

View File

@ -1,2 +0,0 @@
module Units::DefaultsHelper
end

View File

@ -1,26 +1,83 @@
class Unit < ApplicationRecord class Unit < ApplicationRecord
ATTRIBUTES = [:symbol, :description, :multiplier, :base_id]
belongs_to :user, optional: true belongs_to :user, optional: true
belongs_to :base, optional: true, class_name: "Unit" 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 validate if: ->{ base.present? } do
errors.add(:base, :user_mismatch) unless user == base.user errors.add(:base, :user_mismatch) unless user == base.user
errors.add(:base, :multilevel_nesting) if base.base.present? errors.add(:base, :multilevel_nesting) if base.base.present?
end end
validates :symbol, presence: true, uniqueness: {scope: :user_id}, validates :symbol, presence: true, uniqueness: {scope: :user_id},
length: {maximum: columns_hash['symbol'].limit} length: {maximum: type_for_attribute(:symbol).limit}
validates :name, length: {maximum: columns_hash['name'].limit} validates :description, length: {maximum: type_for_attribute(:description).limit}
validates :multiplier, numericality: {equal_to: 1}, unless: :base 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, ->{ 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, ->{ scope :ordered, ->{
parent_symbol = Arel::Nodes::NamedFunction.new(
'COALESCE',
[Arel::Table.new(:bases_units)[:symbol], arel_table[:symbol]]
)
left_outer_joins(:base) 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 before_destroy do
@ -28,7 +85,23 @@ class Unit < ApplicationRecord
nil nil
end end
def to_s
symbol
end
def movable? def movable?
subunits.empty? subunits.empty?
end 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 end

View File

@ -11,7 +11,15 @@ class User < ApplicationRecord
disabled: 0, # administratively disallowed to sign in disabled: 0, # administratively disallowed to sign in
}, default: :active }, 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) def at_least(status)
User.statuses[self.status] >= User.statuses[status] User.statuses[self.status] >= User.statuses[status]

View File

@ -0,0 +1,23 @@
<%= tag.tr do %>
<td class="<%= class_names({subunit: unit.base, grayed: unit.default?}) %>">
<%= unit %>
</td>
<td class="actions">
<% 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 %>
</td>
<% end %>

View File

@ -0,0 +1,22 @@
<div class="rightside buttongrid">
<% 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' %>
</div>
<table class="main items">
<thead>
<tr>
<th><%= User.human_attribute_name(:symbol).capitalize %></th>
<% if current_user.at_least(:active) %>
<th><%= t '.actions' %></th>
<% end %>
</tr>
</thead>
<tbody id="units">
<%= render(@units) || render_no_items %>
</tbody>
</table>

View File

@ -0,0 +1,3 @@
<%= turbo_stream.update :units do %>
<%= render(@units) || render_no_items %>
<% end %>

View File

@ -29,7 +29,7 @@
<%= image_link_to t(".issue_tracker"), "bug-outline", issue_tracker_url, <%= image_link_to t(".issue_tracker"), "bug-outline", issue_tracker_url,
class: "extendedright" %> class: "extendedright" %>
<% if user_signed_in? %> <% 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) {} %> edit_user_registration_path) {} %>
<% if current_user_disguised? %> <% if current_user_disguised? %>
<%= image_link_to t(".revert"), "incognito-off", revert_users_path %> <%= image_link_to t(".revert"), "incognito-off", revert_users_path %>

View File

@ -4,24 +4,26 @@
<td class="<%= class_names({subunit: @unit.base}) %>"> <td class="<%= class_names({subunit: @unit.base}) %>">
<%= form.text_field :symbol, form: :unit_form, required: true, autofocus: true, size: 12, <%= 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" %>
</td> </td>
<td> <td>
<%= form.text_field :name, form: :unit_form, size: 30, <%= form.text_area :description, form: :unit_form, cols: 30, rows: 1, escape: false,
maxlength: @unit.class.columns_hash['name'].limit, autocomplete: "off" %> maxlength: @unit.class.type_for_attribute(:description).limit, autocomplete: "off" %>
</td> </td>
<td> <td>
<% unless @unit.base.nil? %> <% unless @unit.base.nil? %>
<%= form.hidden_field :base_id, form: :unit_form %> <%= form.hidden_field :base_id, form: :unit_form %>
<%= form.number_field :multiplier, form: :unit_form, required: true, step: "any", <%= form.number_field :multiplier, form: :unit_form, required: true,
size: 10, autocomplete: "off" %> size: 10, autocomplete: "off",
**number_attributes(@unit.class.type_for_attribute(:multiplier)) %>
<% end %> <% end %>
</td> </td>
<td class="actions"> <td class="actions">
<%= form.submit form: :unit_form %> <%= 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}) %> name: :cancel, onclick: render_turbo_stream('form_close', {link_id: link_id}) %>
</td> </td>
<td></td>
<% end %> <% end %>
<% end %> <% end %>

View File

@ -5,21 +5,21 @@
data: {drag_path: rebase_unit_path(unit), drop_id: dom_id(unit.base || unit)} do %> data: {drag_path: rebase_unit_path(unit), drop_id: dom_id(unit.base || unit)} do %>
<td class="<%= class_names('link', {subunit: unit.base}) %>"> <td class="<%= class_names('link', {subunit: unit.base}) %>">
<%= 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} %> onclick: 'this.blur();', data: {turbo_stream: true} %>
</td> </td>
<td><%= unit.name %></td> <td><%= unit.description %></td>
<td class="number"><%= scientifize(unit.multiplier) %></td> <td class="number"><%= unit.multiplier.to_html %></td>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<td class="actions"> <td class="actions">
<% if unit.base.nil? %> <% 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();', id: dom_id(unit, :add), onclick: 'this.blur();',
data: {turbo_stream: true} %> data: {turbo_stream: true} %>
<% end %> <% 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 %> method: :delete %>
</td> </td>
<% if unit.movable? %> <% if unit.movable? %>

View File

@ -1,2 +0,0 @@
<h1>Units::Defaults#index</h1>
<p>Find me in app/views/units/defaults/index.html.erb</p>

View File

@ -2,9 +2,8 @@
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<%= image_link_to t('.add_unit'), 'plus-outline', new_unit_path, id: :add_unit, <%= image_link_to t('.add_unit'), 'plus-outline', new_unit_path, id: :add_unit,
onclick: 'this.blur();', data: {turbo_stream: true} %> onclick: 'this.blur();', data: {turbo_stream: true} %>
<%= image_link_to t('.import_units'), 'import', new_unit_path, class: 'tools',
data: {turbo_stream: true} %>
<% end %> <% end %>
<%= image_link_to t('.import_units'), 'download-outline', default_units_path, class: 'tools' %>
</div> </div>
<%= tag.div id: :unit_form %> <%= tag.div id: :unit_form %>
@ -13,7 +12,7 @@
<thead> <thead>
<tr> <tr>
<th><%= User.human_attribute_name(:symbol).capitalize %></th> <th><%= User.human_attribute_name(:symbol).capitalize %></th>
<th><%= User.human_attribute_name(:name).capitalize %></th> <th><%= User.human_attribute_name(:description).capitalize %></th>
<th><%= User.human_attribute_name(:multiplier).capitalize %></th> <th><%= User.human_attribute_name(:multiplier).capitalize %></th>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<th><%= t :actions %></th> <th><%= t :actions %></th>

View File

@ -11,7 +11,7 @@
<tbody> <tbody>
<% @users.each do |user| %> <% @users.each do |user| %>
<tr> <tr>
<td class="link"><%= link_to user.email, user_path(user) %></td> <td class="link"><%= link_to user, user_path(user) %></td>
<td> <td>
<% if user == current_user %> <% if user == current_user %>
<%= user.status %> <%= user.status %>

View File

@ -1,3 +1,3 @@
<p>Hello <%= @resource.email %>!</p> <p>Hello <%= @resource %>!</p>
<p>We're contacting you to notify you that your password has been changed.</p> <p>We're contacting you to notify you that your password has been changed.</p>

View File

@ -1,4 +1,4 @@
<p>Hello <%= @resource.email %>!</p> <p>Hello <%= @resource %>!</p>
<p>Someone has requested a link to change your password. You can do this through the link below.</p> <p>Someone has requested a link to change your password. You can do this through the link below.</p>

View File

@ -1,4 +1,4 @@
<p>Hello <%= @resource.email %>!</p> <p>Hello <%= @resource %>!</p>
<p>Your account has been locked due to an excessive number of unsuccessful sign in attempts.</p> <p>Your account has been locked due to an excessive number of unsuccessful sign in attempts.</p>

View File

@ -1,6 +1,6 @@
<% content_for :navigation, flush: true do %> <% content_for :navigation, flush: true do %>
<%= image_link_to t(:back), 'arrow-left-bold-outline', <%= link_to svg_tag("pictograms/arrow-left-bold-outline") + t(:back),
request.referer.present? ? :back : root_path %> request.referer.present? ? :back : root_path, class: 'tab' %>
<% end %> <% end %>
<div class="rightside buttongrid"> <div class="rightside buttongrid">

View File

@ -23,6 +23,10 @@ module FixinMe
# Initialize configuration defaults for originally generated Rails version. # Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.0 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. # Configuration for the application, engines, and railties goes here.
# #
# These settings can be overridden in specific environments using the files # 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.eager_load_paths << Rails.root.join("extras")
config.action_dispatch.rescue_responses['ApplicationController::AccessForbidden'] = :forbidden 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. # SETUP: Below settings need to be updated on a per-installation basis.
# #
@ -47,5 +51,8 @@ module FixinMe
# Email address of admin account # Email address of admin account
config.admin = 'admin@localhost' config.admin = 'admin@localhost'
# Sender address of account registration-related messages
Devise.mailer_sender = 'noreply@localhost'
end end
end end

View File

@ -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

View File

@ -24,7 +24,8 @@ Devise.setup do |config|
# Configure the e-mail address which will be shown in Devise::Mailer, # 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 # note that it will be overwritten if you use your own mailer class
# with default "from" parameter. # 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. # Configure the class responsible to send e-mails.
# config.mailer = 'Devise::Mailer' # config.mailer = 'Devise::Mailer'

View File

@ -1,3 +0,0 @@
ActiveSupport.on_load :action_dispatch_system_test_case do
prepend CoreExt::ActionDispatch::SystemTesting::TestHelpers::CustomScreenshotHelperUniqueId
end

View File

@ -1,4 +1,8 @@
en: en:
errors:
messages:
precision_exceeded: must not exceed %{value} significant digits
scale_exceeded: must not exceed %{value} decimal digits
activerecord: activerecord:
attributes: attributes:
unit: unit:
@ -33,6 +37,9 @@ en:
forbidden: > forbidden: >
You have not been granted access to this action (403 Forbidden). You have not been granted access to this action (403 Forbidden).
This should not happen, please notify site administrator. 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: > unprocessable_entity: >
The request is semantically incorrect and was rejected (422 Unprocessable Entity). The request is semantically incorrect and was rejected (422 Unprocessable Entity).
This should not happen, please notify site administrator. This should not happen, please notify site administrator.
@ -54,22 +61,39 @@ en:
delete_unit: Delete delete_unit: Delete
index: index:
add_unit: Add unit add_unit: Add unit
import_units: Import... import_units: Import
no_items: There are no configured units. You can try to import some defaults. 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 top_level_drop: Drop here to reposition into top-level unit
new: new:
none: none none: none
create: create:
success: Created new unit success: Created new unit "%{unit}"
update: update:
success: Updated unit success: Updated unit "%{unit}"
rebase: 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: 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: users:
index: index:
disguise: View as... disguise: View as
passwords: passwords:
edit: edit:
new_password: New password new_password: New password

View File

@ -3,22 +3,19 @@ Rails.application.routes.draw do
controllers: {registrations: :registrations} controllers: {registrations: :registrations}
resources :units, except: [:show], path_names: {new: '(/:id)/new'} do resources :units, except: [:show], path_names: {new: '(/:id)/new'} do
member do member { post :rebase }
post :rebase
end
end end
namespace :units do namespace :default do
get 'defaults/index' resources :units, only: [:index, :destroy] do
member { post :import, :export }
#collection { post :import_all }
end
end end
resources :users, only: [:index, :show, :update] do resources :users, only: [:index, :show, :update] do
member do member { get :disguise }
get :disguise collection { get :revert }
end
collection do
get :revert
end
end end
devise_scope :user do devise_scope :user do

View File

@ -2,12 +2,12 @@ class CreateUnits < ActiveRecord::Migration[7.0]
def change def change
create_table :units do |t| create_table :units do |t|
t.references :user, foreign_key: true t.references :user, foreign_key: true
t.string :symbol t.string :symbol, null: false, limit: 15
t.string :name t.text :description
t.decimal :multiplier, precision: 30, scale: 15, default: 1.0 t.decimal :multiplier, null: false, precision: 30, scale: 15, default: 1.0
t.references :base t.references :base
t.timestamps t.timestamps null: false
end end
add_index :units, [:user_id, :symbol], unique: true add_index :units, [:user_id, :symbol], unique: true
end end

View File

@ -10,12 +10,12 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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| create_table "units", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id" t.bigint "user_id"
t.string "symbol" t.string "symbol", limit: 15, null: false
t.string "name" t.text "description"
t.decimal "multiplier", precision: 30, scale: 15, default: "1.0" t.decimal "multiplier", precision: 30, scale: 15, default: "1.0", null: false
t.bigint "base_id" t.bigint "base_id"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false

View File

@ -20,19 +20,4 @@ end
# Formulas will be deleted as dependent on Quantities # Formulas will be deleted as dependent on Quantities
#[Source, Quantity, Unit].each { |model| model.defaults.delete_all } #[Source, Quantity, Unit].each { |model| model.defaults.delete_all }
Unit.transaction do require 'seeds/units.rb'
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

View File

@ -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

36
db/seeds/units.rb Normal file
View File

@ -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

View File

@ -1,4 +1,4 @@
module CoreExt::ActionDispatch::SystemTesting::TestHelpers::CustomScreenshotHelperUniqueId module CoreExt::ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelperUniqueId
private private
def unique def unique

View File

@ -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

View File

@ -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 += "&times;"
end
end
if exponent != 1
result += "10<sup>% d</sup>" % [exponent-1]
end
result.html_safe
end
end
end
BigDecimal.prepend CoreExt::BigDecimalScientificNotation

12
lib/tasks/db.rake Normal file
View File

@ -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

View File

@ -1,10 +1,13 @@
require "test_helper" require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
extend ActionView::Helpers::TranslationHelper
include ActionView::Helpers::UrlHelper include ActionView::Helpers::UrlHelper
# NOTE: geckodriver installed with Firefox, ignore incompatibility warning # 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| Capybara.configure do |config|
config.save_path = "#{Rails.root}/tmp/screenshots/" config.save_path = "#{Rails.root}/tmp/screenshots/"
end end
@ -30,4 +33,18 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
#def assert_stale(element) #def assert_stale(element)
# assert_raises(Selenium::WebDriver::Error::StaleElementReferenceError) { element.tag_name } # assert_raises(Selenium::WebDriver::Error::StaleElementReferenceError) { element.tag_name }
#end #end
# HTML does not allow [disabled] attribute on <a> tag, so it's not possible to
# easily find them using e.g. :link selector
#Capybara.add_selector(:disabled_link) do
# label "<a> 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 end

View File

@ -1,6 +1,6 @@
require "test_helper" require "test_helper"
class Units::DefaultsControllerTest < ActionDispatch::IntegrationTest class Default::UnitsControllerTest < ActionDispatch::IntegrationTest
test "should get index" do test "should get index" do
get units_defaults_index_url get units_defaults_index_url
assert_response :success assert_response :success

View File

@ -1,40 +1,40 @@
g: g:
user: admin user: admin
symbol: g symbol: g
name: gram description: gram
kg: kg:
user: admin user: admin
symbol: kg symbol: kg
name: kilogram description: kilogram
multiplier: 1000 multiplier: 1000
base: g base: g
1: 1:
user: admin user: admin
symbol: 1 symbol: 1
name: one description: one
s: s:
user: admin user: admin
symbol: s symbol: s
name: second description: second
percent: percent:
user: admin user: admin
symbol: '%' symbol: '%'
name: percent description: percent
multiplier: 0.01 multiplier: 0.01
base: 1 base: 1
µg: µg:
user: admin user: admin
symbol: µg symbol: µg
name: microgram description: microgram
multiplier: 0.000001 multiplier: 0.000001
base: g base: g
mg: mg:
user: admin user: admin
symbol: mg symbol: mg
name: milligram description: milligram
multiplier: 0.001 multiplier: 0.001
base: g base: g
g_alice: g_alice:
user: alice user: alice
symbol: g symbol: g
name: gram description: gram

View File

@ -1,8 +1,16 @@
require "application_system_test_case" require "application_system_test_case"
class UnitsTest < ApplicationSystemTestCase class UnitsTest < ApplicationSystemTestCase
LINK_LABELS = {
add_unit: t('units.index.add_unit'),
add_subunit: t('units.unit.add_subunit'),
edit: nil
}
setup do setup do
@user = sign_in @user = sign_in
LINK_LABELS[:edit] = Regexp.union(@user.units.map(&:symbol))
visit units_path visit units_path
end end
@ -12,7 +20,8 @@ class UnitsTest < ApplicationSystemTestCase
assert_selector 'tr', count: @user.units.count assert_selector 'tr', count: @user.units.count
end end
Unit.destroy_all # Cannot #destroy_all due to {dependent: :restrict*} on Unit.subunits association
@user.units.delete_all
visit units_path visit units_path
within 'tbody' do within 'tbody' do
assert_selector 'tr', count: 1 assert_selector 'tr', count: 1
@ -20,16 +29,24 @@ class UnitsTest < ApplicationSystemTestCase
end end
end end
test "add unit" do test "new" do
click_on t('units.index.add_unit') 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 within 'tbody > tr:has(input[type=text], textarea)' do
assert_selector ':focus' 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]', fill_in 'unit[symbol]',
with: SecureRandom.random_symbol(rand([1..15, 15..maxlength['unit[symbol]']].sample)) with: random_string(rand([1..3, 4..maxlength['unit[symbol]']].sample))
fill_in 'unit[name]', fill_in 'unit[description]', with: random_string(rand(0..maxlength['unit[description]']))
with: [nil, SecureRandom.alphanumeric(rand(1..maxlength['unit[name]']))].sample 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 assert_difference ->{ Unit.count }, 1 do
click_on t('helpers.submit.create') click_on t('helpers.submit.create')
end end
@ -39,39 +56,48 @@ class UnitsTest < ApplicationSystemTestCase
assert_no_selector :fillable_field assert_no_selector :fillable_field
assert_selector 'tr', count: @user.units.count assert_selector 'tr', count: @user.units.count
end 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 end
test "add and edit disallow opening multiple forms" do # TODO: check proper form/button redisplay and flash messages on add/edit
# Once new/edit form is open, other actions on the same page either replace test "new and edit form on validation error" do
# the form or leave it untouched end
# TODO: add non-empty form closing warning
# 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 = {} links = {}
link_labels = {1 => [t('units.index.add_unit'), t('units.unit.add_subunit')], targets = {}
0 => units.map(&:symbol)} LINK_LABELS.each_pair do |type, labels|
link_labels.each_pair do |row_change, labels| links[type] = all(:link_or_button, exact_text: labels).to_a
all(:link_or_button, exact_text: Regexp.union(labels)).map { |l| links[l] = row_change } targets[type] = links[type].sample
end 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 assert_difference ->{ all('tbody tr').count }, rows do
link.click link.click
end end
find 'tbody tr:has(input[type=text]:focus)' within('tbody tr:has(input[type=text])') { assert_selector ':focus' }
if type == :edit
# Link should be now unavailable or unclickable assert !link.visible?
begin [:add_subunit, :edit].each do |t|
assert_raises(Selenium::WebDriver::Error::ElementClickInterceptedError) do assert_difference(->{ links[t].length }, -1) { links[t].select!(&:visible?) }
link.click end
end if link.visible? else
rescue Selenium::WebDriver::Error::StaleElementReferenceError assert link[:disabled]
link = nil
end end
link = links.keys.select(&:visible?).sample targets.merge([:add_subunit, :edit].map { |t| [t, links[t].sample] }.to_h)
assert_difference ->{ all('tbody tr').count }, links[link] - rows do type, link = targets.assoc(targets.keys.sample)
assert_difference ->{ all('tbody tr').count }, row_change[type] - rows do
link.click link.click
end end
assert_selector 'tbody tr:has(input[type=text]:focus)', count: 1 within('tbody tr:has(input[type=text])') { assert_selector ':focus' }
end end
# NOTE: extend with any add/edit link # NOTE: extend with any add/edit link

View File

@ -9,15 +9,41 @@ class ActiveSupport::TestCase
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all fixtures :all
include AbstractController::Translation
include ActionMailer::TestHelper include ActionMailer::TestHelper
include ActionView::Helpers::TranslationHelper
# NOTE: use public #alphanumeric(chars: ...) from Ruby 3.3 onwards # List of categorized Unicode characters:
SecureRandom.class_eval do # * http://www.unicode.org/Public/UNIDATA/UnicodeData.txt
def self.random_symbol(n = 10) # File format: http://www.unicode.org/L2/L1999/UnicodeData.html
# Unicode characters: 32-126, 160-383 # Select from graphic ranges: L, M, N, P, S, Zs
choose([*' '..'~', 160.chr(Encoding::UTF_8), *'¡'..'ſ'], n) 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 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 end
def randomize_user_password!(user) def randomize_user_password!(user)
@ -25,11 +51,11 @@ class ActiveSupport::TestCase
end end
def random_password def random_password
SecureRandom.alphanumeric rand(Rails.configuration.devise.password_length) Random.alphanumeric rand(Rails.configuration.devise.password_length)
end end
def random_email 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 end
def with_last_email def with_last_email