forked from fixin.me/fixin.me
		
	Merging from main master to my repo master. #4
							
								
								
									
										2
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								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"
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										245
									
								
								Gemfile.lock
									
									
									
									
									
								
							
							
						
						
									
										245
									
								
								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
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										19
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								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
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								app/assets/images/pictograms/close-outline.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								app/assets/images/pictograms/close-outline.svg
									
									
									
									
									
										Normal 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  | 
@ -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  | 
							
								
								
									
										1
									
								
								app/assets/images/pictograms/download-outline.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								app/assets/images/pictograms/download-outline.svg
									
									
									
									
									
										Normal 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  | 
							
								
								
									
										1
									
								
								app/assets/images/pictograms/upload-outline.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								app/assets/images/pictograms/upload-outline.svg
									
									
									
									
									
										Normal 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  | 
@ -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),
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										54
									
								
								app/controllers/default/units_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								app/controllers/default/units_controller.rb
									
									
									
									
									
										Normal 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
 | 
			
		||||
@ -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
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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<sup>% d</sup>" % [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
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2
									
								
								app/helpers/default/units_helper.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								app/helpers/default/units_helper.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,2 @@
 | 
			
		||||
module Default::UnitsHelper
 | 
			
		||||
end
 | 
			
		||||
@ -1,2 +0,0 @@
 | 
			
		||||
module Units::DefaultsHelper
 | 
			
		||||
end
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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]
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										23
									
								
								app/views/default/units/_unit.html.erb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								app/views/default/units/_unit.html.erb
									
									
									
									
									
										Normal 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 %>
 | 
			
		||||
							
								
								
									
										22
									
								
								app/views/default/units/index.html.erb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								app/views/default/units/index.html.erb
									
									
									
									
									
										Normal 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>
 | 
			
		||||
							
								
								
									
										3
									
								
								app/views/default/units/index.turbo_stream.erb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								app/views/default/units/index.turbo_stream.erb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,3 @@
 | 
			
		||||
<%= turbo_stream.update :units do %>
 | 
			
		||||
  <%= render(@units) || render_no_items  %>
 | 
			
		||||
<% end %>
 | 
			
		||||
@ -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 %>
 | 
			
		||||
 | 
			
		||||
@ -4,24 +4,26 @@
 | 
			
		||||
 | 
			
		||||
    <td class="<%= class_names({subunit: @unit.base}) %>">
 | 
			
		||||
      <%= 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>
 | 
			
		||||
      <%= 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" %>
 | 
			
		||||
    </td>
 | 
			
		||||
    <td>
 | 
			
		||||
      <% 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 %>
 | 
			
		||||
    </td>
 | 
			
		||||
 | 
			
		||||
    <td class="actions">
 | 
			
		||||
      <%= 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}) %>
 | 
			
		||||
    </td>
 | 
			
		||||
    <td></td>
 | 
			
		||||
  <% end %>
 | 
			
		||||
<% end %>
 | 
			
		||||
 | 
			
		||||
@ -5,21 +5,21 @@
 | 
			
		||||
           data: {drag_path: rebase_unit_path(unit), drop_id: dom_id(unit.base || unit)} do %>
 | 
			
		||||
 | 
			
		||||
  <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} %>
 | 
			
		||||
  </td>
 | 
			
		||||
  <td><%= unit.name %></td>
 | 
			
		||||
  <td class="number"><%= scientifize(unit.multiplier) %></td>
 | 
			
		||||
  <td><%= unit.description %></td>
 | 
			
		||||
  <td class="number"><%= unit.multiplier.to_html %></td>
 | 
			
		||||
 | 
			
		||||
  <% if current_user.at_least(:active) %>
 | 
			
		||||
    <td class="actions">
 | 
			
		||||
      <% 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 %>
 | 
			
		||||
    </td>
 | 
			
		||||
    <% if unit.movable? %>
 | 
			
		||||
 | 
			
		||||
@ -1,2 +0,0 @@
 | 
			
		||||
<h1>Units::Defaults#index</h1>
 | 
			
		||||
<p>Find me in app/views/units/defaults/index.html.erb</p>
 | 
			
		||||
@ -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' %>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<%= tag.div id: :unit_form %>
 | 
			
		||||
@ -13,7 +12,7 @@
 | 
			
		||||
  <thead>
 | 
			
		||||
    <tr>
 | 
			
		||||
      <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>
 | 
			
		||||
      <% if current_user.at_least(:active) %>
 | 
			
		||||
        <th><%= t :actions %></th>
 | 
			
		||||
 | 
			
		||||
@ -11,7 +11,7 @@
 | 
			
		||||
  <tbody>
 | 
			
		||||
    <% @users.each do |user| %>
 | 
			
		||||
      <tr>
 | 
			
		||||
        <td class="link"><%= link_to user.email, user_path(user) %></td>
 | 
			
		||||
        <td class="link"><%= link_to user, user_path(user) %></td>
 | 
			
		||||
        <td>
 | 
			
		||||
          <% if user == current_user %>
 | 
			
		||||
            <%= user.status %>
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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 %>
 | 
			
		||||
 | 
			
		||||
<div class="rightside buttongrid">
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										9
									
								
								config/initializers/core_ext.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								config/initializers/core_ext.rb
									
									
									
									
									
										Normal 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
 | 
			
		||||
@ -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'
 | 
			
		||||
 | 
			
		||||
@ -1,3 +0,0 @@
 | 
			
		||||
ActiveSupport.on_load :action_dispatch_system_test_case do
 | 
			
		||||
  prepend CoreExt::ActionDispatch::SystemTesting::TestHelpers::CustomScreenshotHelperUniqueId
 | 
			
		||||
end
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										17
									
								
								db/seeds.rb
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								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'
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										9
									
								
								db/seeds/templates/units.erb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								db/seeds/templates/units.erb
									
									
									
									
									
										Normal 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
									
								
							
							
						
						
									
										36
									
								
								db/seeds/units.rb
									
									
									
									
									
										Normal 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
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
module CoreExt::ActionDispatch::SystemTesting::TestHelpers::CustomScreenshotHelperUniqueId
 | 
			
		||||
module CoreExt::ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelperUniqueId
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def unique
 | 
			
		||||
@ -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
 | 
			
		||||
							
								
								
									
										36
									
								
								lib/core_ext/big_decimal_scientific_notation.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								lib/core_ext/big_decimal_scientific_notation.rb
									
									
									
									
									
										Normal 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 += "×"
 | 
			
		||||
        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
									
								
							
							
						
						
									
										12
									
								
								lib/tasks/db.rake
									
									
									
									
									
										Normal 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
 | 
			
		||||
@ -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 <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
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
							
								
								
									
										16
									
								
								test/fixtures/units.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										16
									
								
								test/fixtures/units.yml
									
									
									
									
										vendored
									
									
								
							@ -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
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user