6 Commits

55 changed files with 960 additions and 642 deletions

View File

@@ -1,7 +1,7 @@
source "https://rubygems.org" source "https://rubygems.org"
# The requirement for the Ruby version comes from Rails # The requirement for the Ruby version comes from Rails
gem "rails", "~> 7.2.2" gem "rails", "~> 7.2.3"
gem "sprockets-rails" gem "sprockets-rails"
gem "puma", "~> 6.0" gem "puma", "~> 6.0"
gem "sassc-rails" gem "sassc-rails"
@@ -42,4 +42,11 @@ group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara" gem "capybara"
gem "selenium-webdriver" gem "selenium-webdriver"
# Remove minitest version restriction after error fixed:
# railties-7.2.3/lib/rails/test_unit/line_filtering.rb:7:in `run':
# wrong number of arguments (given 3, expected 1..2) (ArgumentError)
# from /var/www/.gem/ruby/3.3.0/gems/minitest-6.0.2/lib/minitest.rb:473:in
# `block (2 levels) in run_suite'
gem "minitest", "< 6"
end end

View File

@@ -1,66 +1,68 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.2.2.1) actioncable (7.2.3)
actionpack (= 7.2.2.1) actionpack (= 7.2.3)
activesupport (= 7.2.2.1) activesupport (= 7.2.3)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (7.2.2.1) actionmailbox (7.2.3)
actionpack (= 7.2.2.1) actionpack (= 7.2.3)
activejob (= 7.2.2.1) activejob (= 7.2.3)
activerecord (= 7.2.2.1) activerecord (= 7.2.3)
activestorage (= 7.2.2.1) activestorage (= 7.2.3)
activesupport (= 7.2.2.1) activesupport (= 7.2.3)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (7.2.2.1) actionmailer (7.2.3)
actionpack (= 7.2.2.1) actionpack (= 7.2.3)
actionview (= 7.2.2.1) actionview (= 7.2.3)
activejob (= 7.2.2.1) activejob (= 7.2.3)
activesupport (= 7.2.2.1) activesupport (= 7.2.3)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (7.2.2.1) actionpack (7.2.3)
actionview (= 7.2.2.1) actionview (= 7.2.3)
activesupport (= 7.2.2.1) activesupport (= 7.2.3)
cgi
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
racc racc
rack (>= 2.2.4, < 3.2) rack (>= 2.2.4, < 3.3)
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)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (7.2.2.1) actiontext (7.2.3)
actionpack (= 7.2.2.1) actionpack (= 7.2.3)
activerecord (= 7.2.2.1) activerecord (= 7.2.3)
activestorage (= 7.2.2.1) activestorage (= 7.2.3)
activesupport (= 7.2.2.1) activesupport (= 7.2.3)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.2.2.1) actionview (7.2.3)
activesupport (= 7.2.2.1) activesupport (= 7.2.3)
builder (~> 3.1) builder (~> 3.1)
cgi
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.2.2.1) activejob (7.2.3)
activesupport (= 7.2.2.1) activesupport (= 7.2.3)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.2.2.1) activemodel (7.2.3)
activesupport (= 7.2.2.1) activesupport (= 7.2.3)
activerecord (7.2.2.1) activerecord (7.2.3)
activemodel (= 7.2.2.1) activemodel (= 7.2.3)
activesupport (= 7.2.2.1) activesupport (= 7.2.3)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (7.2.2.1) activestorage (7.2.3)
actionpack (= 7.2.2.1) actionpack (= 7.2.3)
activejob (= 7.2.2.1) activejob (= 7.2.3)
activerecord (= 7.2.2.1) activerecord (= 7.2.3)
activesupport (= 7.2.2.1) activesupport (= 7.2.3)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (7.2.2.1) activesupport (7.2.3)
base64 base64
benchmark (>= 0.3) benchmark (>= 0.3)
bigdecimal bigdecimal
@@ -72,15 +74,16 @@ GEM
minitest (>= 5.1) minitest (>= 5.1)
securerandom (>= 0.3) securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5) tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.7) addressable (2.8.8)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 8.0)
base64 (0.2.0) base64 (0.3.0)
bcrypt (3.1.20) bcrypt (3.1.21)
benchmark (0.4.0) benchmark (0.5.0)
bigdecimal (3.1.9) bigdecimal (4.0.1)
bindex (0.8.1) bindex (0.8.1)
builder (3.3.0) builder (3.3.0)
byebug (12.0.0) byebug (13.0.0)
reline (>= 0.6.0)
capybara (3.40.0) capybara (3.40.0)
addressable addressable
matrix matrix
@@ -90,54 +93,59 @@ GEM
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.3.5) cgi (0.5.1)
connection_pool (2.5.2) concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6) crass (1.0.6)
date (3.4.1) date (3.5.1)
devise (4.9.4) devise (5.0.2)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 4.1.0) railties (>= 7.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
drb (2.2.1) drb (2.2.3)
erb (6.0.2)
erubi (1.13.1) erubi (1.13.1)
ffi (1.17.2-aarch64-linux-gnu) ffi (1.17.3-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl) ffi (1.17.3-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu) ffi (1.17.3-arm-linux-gnu)
ffi (1.17.2-arm-linux-musl) ffi (1.17.3-arm-linux-musl)
ffi (1.17.2-arm64-darwin) ffi (1.17.3-arm64-darwin)
ffi (1.17.2-x86_64-darwin) ffi (1.17.3-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu) ffi (1.17.3-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl) ffi (1.17.3-x86_64-linux-musl)
globalid (1.2.1) globalid (1.3.0)
activesupport (>= 6.1) activesupport (>= 6.1)
i18n (1.14.7) i18n (1.14.8)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
importmap-rails (2.1.0) importmap-rails (2.2.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.8.0) io-console (0.8.2)
irb (1.15.2) irb (1.17.0)
pp (>= 0.6.0) pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
logger (1.7.0) logger (1.7.0)
loofah (2.24.0) loofah (2.25.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.9.0)
logger
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
marcel (1.0.4) marcel (1.1.0)
matrix (0.4.2) matrix (0.4.3)
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.25.5) minitest (5.27.0)
mysql2 (0.5.6) mysql2 (0.5.7)
net-imap (0.5.7) bigdecimal
net-imap (0.6.3)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -146,83 +154,94 @@ GEM
timeout timeout
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
nio4r (2.7.4) nio4r (2.7.5)
nokogiri (1.18.8-aarch64-linux-gnu) nokogiri (1.19.1-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-aarch64-linux-musl) nokogiri (1.19.1-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-arm-linux-gnu) nokogiri (1.19.1-arm-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-arm-linux-musl) nokogiri (1.19.1-arm-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-arm64-darwin) nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-darwin) nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-gnu) nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-musl) nokogiri (1.19.1-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
orm_adapter (0.5.0) orm_adapter (0.5.0)
pg (1.5.9) pg (1.6.3)
pp (0.6.2) pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
pp (0.6.3)
prettyprint prettyprint
prettyprint (0.2.0) prettyprint (0.2.0)
psych (5.2.3) prism (1.9.0)
psych (5.3.1)
date date
stringio stringio
public_suffix (6.0.1) public_suffix (7.0.2)
puma (6.6.0) puma (6.6.1)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.1.13) rack (3.2.5)
rack-session (2.1.0) rack-session (2.1.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.2.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.2.1) rackup (2.3.1)
rack (>= 3) rack (>= 3)
rails (7.2.2.1) rails (7.2.3)
actioncable (= 7.2.2.1) actioncable (= 7.2.3)
actionmailbox (= 7.2.2.1) actionmailbox (= 7.2.3)
actionmailer (= 7.2.2.1) actionmailer (= 7.2.3)
actionpack (= 7.2.2.1) actionpack (= 7.2.3)
actiontext (= 7.2.2.1) actiontext (= 7.2.3)
actionview (= 7.2.2.1) actionview (= 7.2.3)
activejob (= 7.2.2.1) activejob (= 7.2.3)
activemodel (= 7.2.2.1) activemodel (= 7.2.3)
activerecord (= 7.2.2.1) activerecord (= 7.2.3)
activestorage (= 7.2.2.1) activestorage (= 7.2.3)
activesupport (= 7.2.2.1) activesupport (= 7.2.3)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.2.2.1) railties (= 7.2.3)
rails-dom-testing (2.2.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.7.0)
loofah (~> 2.21) loofah (~> 2.25)
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) 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.1) railties (7.2.3)
actionpack (= 7.2.2.1) actionpack (= 7.2.3)
activesupport (= 7.2.2.1) activesupport (= 7.2.3)
cgi
irb (~> 1.13) 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)
tsort (>= 0.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rake (13.2.1) rake (13.3.1)
rdoc (6.13.1) rdoc (7.2.0)
erb
psych (>= 4.0.0) psych (>= 4.0.0)
regexp_parser (2.10.0) tsort
reline (0.6.1) regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5) io-console (~> 0.5)
responders (3.1.1) responders (3.2.0)
actionpack (>= 5.2) actionpack (>= 7.0)
railties (>= 5.2) railties (>= 7.0)
rexml (3.4.1) rexml (3.4.4)
rubyzip (2.4.1) rubyzip (3.2.2)
sassc (2.4.0) sassc (2.4.0)
ffi (~> 1.9) ffi (~> 1.9)
sassc-rails (2.1.2) sassc-rails (2.1.2)
@@ -232,11 +251,11 @@ GEM
sprockets-rails sprockets-rails
tilt tilt
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.31.0) selenium-webdriver (4.41.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) 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, < 4.0)
websocket (~> 1.0) websocket (~> 1.0)
sprockets (4.2.2) sprockets (4.2.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
@@ -246,19 +265,20 @@ GEM
actionpack (>= 6.1) actionpack (>= 6.1)
activesupport (>= 6.1) activesupport (>= 6.1)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
sqlite3 (2.7.3-aarch64-linux-gnu) sqlite3 (2.9.0-aarch64-linux-gnu)
sqlite3 (2.7.3-aarch64-linux-musl) sqlite3 (2.9.0-aarch64-linux-musl)
sqlite3 (2.7.3-arm-linux-gnu) sqlite3 (2.9.0-arm-linux-gnu)
sqlite3 (2.7.3-arm-linux-musl) sqlite3 (2.9.0-arm-linux-musl)
sqlite3 (2.7.3-arm64-darwin) sqlite3 (2.9.0-arm64-darwin)
sqlite3 (2.7.3-x86_64-darwin) sqlite3 (2.9.0-x86_64-darwin)
sqlite3 (2.7.3-x86_64-linux-gnu) sqlite3 (2.9.0-x86_64-linux-gnu)
sqlite3 (2.7.3-x86_64-linux-musl) sqlite3 (2.9.0-x86_64-linux-musl)
stringio (3.1.7) stringio (3.2.0)
thor (1.3.2) thor (1.5.0)
tilt (2.6.0) tilt (2.7.0)
timeout (0.4.3) timeout (0.6.0)
turbo-rails (2.0.13) tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0) actionpack (>= 7.1.0)
railties (>= 7.1.0) railties (>= 7.1.0)
tzinfo (2.0.6) tzinfo (2.0.6)
@@ -272,13 +292,13 @@ GEM
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 6.0.0)
websocket (1.2.11) websocket (1.2.11)
websocket-driver (0.7.7) websocket-driver (0.8.0)
base64 base64
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.7.2) zeitwerk (2.7.5)
PLATFORMS PLATFORMS
aarch64-linux-gnu aarch64-linux-gnu
@@ -295,10 +315,11 @@ DEPENDENCIES
capybara capybara
devise devise
importmap-rails importmap-rails
minitest (< 6)
mysql2 (~> 0.5) mysql2 (~> 0.5)
pg (~> 1.5) pg (~> 1.5)
puma (~> 6.0) puma (~> 6.0)
rails (~> 7.2.2) rails (~> 7.2.3)
sassc-rails sassc-rails
selenium-webdriver selenium-webdriver
sprockets-rails sprockets-rails

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z" /></svg>

After

Width:  |  Height:  |  Size: 148 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M14.06,9L15,9.94L5.92,19H5V18.08L14.06,9M17.66,3C17.41,3 17.15,3.1 16.96,3.29L15.13,5.12L18.88,8.87L20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18.17,3.09 17.92,3 17.66,3M14.06,6.19L3,17.25V21H6.75L17.81,9.94L14.06,6.19Z" /></svg>

After

Width:  |  Height:  |  Size: 316 B

View File

@@ -15,6 +15,13 @@
*= require_self *= require_self
*/ */
/* Strive for simplicity:
* * style elements/tags only - if possible,
* * replace element/tag name with class name - if element has to be styled
* differently depending on context (e.g. form)
*
* NOTE: Style in a modular way, similar to how CSS @scope would be used,
* to make transition easier once @scope is widely available */
:root { :root {
--color-focus-gray: #f3f3f3; --color-focus-gray: #f3f3f3;
--color-border-gray: #dddddd; --color-border-gray: #dddddd;
@@ -48,37 +55,24 @@
} }
/* TODO: collapse gaps around empty rows (`topside`) once possible /* Color coding of input controls' background:
* https://github.com/w3c/csswg-drafts/issues/5813 */ * blue - target for interaction with pointer
body { * gray - target for interaction with keyboard
display: grid; * red - destructive, non-undoable action
gap: 0.8em; */
grid-template-areas:
"header header header"
"nav nav nav"
"leftempty topside rightempty"
"leftside main rightside";
grid-template-columns: 1fr auto 1fr;
grid-template-rows: repeat(4, auto);
font-family: system-ui;
margin: 0.4em;
}
button, button,
details,
input, input,
select, select,
textarea { textarea {
background-color: inherit; background-color: inherit;
font: inherit; font: inherit;
} }
details,
input, input,
select { select {
text-align: inherit; text-align: inherit;
} }
/* blue - target for interaction with pointer */
/* gray - target for interaction with keyboard */
/* TODO: remove non-font-size rems from buttons/inputs below */
a, a,
button, button,
input[type=submit] { input[type=submit] {
@@ -86,9 +80,10 @@ input[type=submit] {
text-decoration: none; text-decoration: none;
white-space: nowrap; white-space: nowrap;
} }
/* [hidden] submit controls cannot have `display` set as it makes them visible */
.button, .button,
button, button:not([hidden]),
input[type=submit], input[type=submit]:not([hidden]),
.tab { .tab {
align-items: center; align-items: center;
color: var(--color-gray); color: var(--color-gray);
@@ -100,11 +95,12 @@ input[type=submit],
button, button,
input[type=submit] { input[type=submit] {
font-size: 0.8rem; font-size: 0.8rem;
padding: 0.4em; padding: 0.6em 0.5em;
width: fit-content; width: fit-content;
} }
input:not([type=submit]):not([type=checkbox]), input:not([type=submit]):not([type=checkbox]),
select, select,
summary,
textarea { textarea {
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
} }
@@ -112,68 +108,23 @@ textarea {
button, button,
input, input,
select, select,
summary,
textarea { textarea {
border: solid 1px var(--color-gray); border: solid 1px var(--color-gray);
border-radius: 0.25em; border-radius: 0.25em;
} }
fieldset, input[type=checkbox],
svg,
textarea { textarea {
margin: 0 margin: 0
} }
.button > svg,
.tab > svg,
button > svg {
height: 1.8em;
width: 1.8em;
}
.button > svg:not(:last-child),
.tab > svg:not(:last-child),
button > svg:not(:last-child) {
padding-right: 0.4em;
}
fieldset {
padding: 0.4em;
}
legend {
color: var(--color-gray);
display: flex;
gap: 0.4em;
width: 100%;
}
legend span {
align-content: center;
flex-grow: 1;
}
/* TODO: move normal non-button links (<a>:hover/:focus) styling here (i.e.
* page-wide, top-level) and remove from table.items - as the style should be
* same everywhere */
.button:focus-visible,
button:focus-visible,
input[type=submit]:focus-visible {
background-color: var(--color-focus-gray);
}
.button:hover,
button:hover,
input[type=submit]:hover {
background-color: var(--color-blue);
border-color: var(--color-blue);
color: white;
fill: white;
}
.dangerous:hover {
background-color: var(--color-red);
border-color: var(--color-red);
}
input[type=checkbox] { input[type=checkbox] {
accent-color: var(--color-blue); accent-color: var(--color-blue);
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
display: flex; display: flex;
height: 1.1rem; height: 1.1em;
margin: 0; width: 1.1em;
width: 1.1rem;
} }
input[type=checkbox]:checked { input[type=checkbox]:checked {
appearance: checkbox; appearance: checkbox;
@@ -191,42 +142,96 @@ input::-webkit-outer-spin-button {
-webkit-appearance: none; -webkit-appearance: none;
margin: 0; margin: 0;
} }
.button > svg,
.tab > svg,
button > svg {
height: 1.4em;
width: 1.4em;
}
.button > svg:not(:last-child),
.tab > svg:not(:last-child),
button > svg:not(:last-child) {
margin-right: 0.2em;
}
/* TODO: move normal non-button links (<a>:hover/:focus) styling here (i.e.
* page-wide, top-level) and remove from table.items - as the style should be
* same everywhere */
.button:focus-visible,
button:focus-visible,
input[type=submit]:focus-visible {
background-color: var(--color-focus-gray);
}
input:focus-visible,
select:focus-visible,
select:focus-within,
/* TODO: how to achieve summary:focus-within for ::details-content? */
summary:focus-visible,
textarea:focus-visible {
accent-color: var(--color-dark-blue);
background-color: var(--color-focus-gray);
}
.button:hover,
button:hover,
input[type=submit]:hover {
background-color: var(--color-blue);
border-color: var(--color-blue);
color: white;
fill: white;
}
.dangerous:hover {
background-color: var(--color-red);
border-color: var(--color-red);
}
input:hover, input:hover,
select:hover, select:hover,
summary:hover,
textarea:hover { textarea:hover {
border-color: var(--color-blue); border-color: var(--color-blue);
outline: solid 1px var(--color-blue); outline: solid 1px var(--color-blue);
} }
select:hover,
summary:hover {
cursor: pointer;
}
input:invalid, input:invalid,
select:invalid, select:invalid,
textarea:invalid { textarea:invalid {
border-color: var(--color-red); border-color: var(--color-red);
outline: solid 1px var(--color-red); outline: solid 1px var(--color-red);
} }
select:hover {
cursor: pointer;
}
input:focus-visible,
select:focus-within,
select:focus-visible,
textarea:focus-visible {
accent-color: var(--color-dark-blue);
background-color: var(--color-focus-gray);
}
fieldset,
input[type=text]:read-only, input[type=text]:read-only,
textarea:read-only { textarea:read-only {
border: none; border: none;
padding-left: 0; padding-inline: 0;
padding-right: 0;
} }
/* NOTE: collapse gaps around empty rows (`topside`) once possible
* with grid-collapse property and remove alternative grid-template
* https://github.com/w3c/csswg-drafts/issues/5813 */
body {
display: grid;
gap: 0.8em;
grid-template-areas:
"header header header"
"nav nav nav"
"leftside topside rightside"
"leftside main rightside";
grid-template-columns: 1fr minmax(max-content, 2fr) 1fr;
font-family: system-ui;
margin: 0.4em;
}
body:not(:has(.topside-area)) {
grid-template-areas:
"header header header"
"nav nav nav"
"leftside main rightside";
}
header { header {
grid-area: header; grid-area: header;
} }
.navigation { .navigation {
display: flex; display: flex;
grid-area: nav; grid-area: nav;
@@ -239,7 +244,7 @@ header {
flex: 1; flex: 1;
font-size: 1rem; font-size: 1rem;
justify-content: center; justify-content: center;
padding-block: 0.3em; padding-block: 0.4em;
} }
.navigation > .tab:hover, .navigation > .tab:hover,
.navigation > .tab:focus-visible { .navigation > .tab:focus-visible {
@@ -251,21 +256,19 @@ header {
fill: var(--color-blue); fill: var(--color-blue);
} }
.topside-area {
.topside {
grid-area: topside; grid-area: topside;
} }
.leftside { .leftside-area {
grid-area: leftside; grid-area: leftside;
} }
.main { .main-area {
grid-area: main; grid-area: main;
} }
.rightside { .rightside-area {
grid-area: rightside; grid-area: rightside;
} }
.buttongrid { .buttongrid {
display: grid; display: grid;
gap: 0.4em; gap: 0.4em;
@@ -273,7 +276,7 @@ header {
grid-template-columns: auto 1fr auto; grid-template-columns: auto 1fr auto;
grid-template-rows: max-content; grid-template-rows: max-content;
} }
.tools { .tools-area {
grid-area: tools; grid-area: tools;
} }
@@ -337,47 +340,43 @@ header {
opacity: 1; opacity: 1;
} }
/* TODO: Hover over invalid should work like in measurements (thin vs thick border) */
/* TODO: Update styling, including rem removal. */ .labeled-form {
form table { align-items: center;
border-spacing: 0.8rem; display: grid;
gap: 0.9em 1.1em;
grid-template-columns: 1fr minmax(max-content, 0.5fr) 1fr;
} }
form tr td:first-child { .labeled-form label {
color: var(--color-gray); color: var(--color-gray);
font-size: 0.9rem; font-size: 0.9rem;
padding-right: 0.25rem; grid-column: 1;
text-align: right; text-align: right;
white-space: nowrap;
} }
form label.required { .labeled-form label.required {
font-weight: bold; font-weight: bold;
} }
form label.error, /* Don't style `label.error + input` if case already covered by input:invalid */
form td.error::after { .labeled-form label.error {
color: var(--color-red); color: var(--color-red);
} }
form td.error { .labeled-form em {
display: -webkit-box;
}
form td.error::after {
content: attr(data-content);
font-size: 0.9rem;
margin-left: 1rem;
padding: 0.25rem 0;
position: absolute;
}
form em {
color: var(--color-text-gray); color: var(--color-text-gray);
font-size: 0.75rem; font-size: 0.75rem;
font-weight: normal; font-weight: normal;
} }
form input[type=submit] { .labeled-form input {
float: none; grid-column: 2;
}
.labeled-form input[type=submit] {
font-size: 1rem; font-size: 1rem;
margin: 1.5rem auto 0 auto; margin: 1.5em auto 0 auto;
padding: 0.75rem; padding: 0.75em;
} }
/* TODO: remove .items class (?) and make 'form table' work properly */
table.items { table.items {
border-spacing: 0; border-spacing: 0;
border: solid 1px var(--color-border-gray); border: solid 1px var(--color-border-gray);
@@ -385,6 +384,9 @@ table.items {
font-size: 0.85rem; font-size: 0.85rem;
text-align: left; text-align: left;
} }
table:not(:has(tr)) {
display: none;
}
table.items thead { table.items thead {
font-size: 0.8rem; font-size: 0.8rem;
} }
@@ -417,10 +419,10 @@ table.items td.link a::after {
table.items td:first-child { table.items td:first-child {
padding-inline-start: calc(1em + var(--depth) * 0.8em); padding-inline-start: calc(1em + var(--depth) * 0.8em);
} }
table.items td:has(input, textarea) { table.items td:has(input, select, textarea) {
padding-inline-start: calc(0.6em - 0.9px); padding-inline-start: calc(0.6em - 0.9px);
} }
table.items td:first-child:has(input, textarea) { table.items td:first-child:has(input, select, textarea) {
padding-inline-start: calc(0.6em + var(--depth) * 0.8em - 0.9px); padding-inline-start: calc(0.6em + var(--depth) * 0.8em - 0.9px);
} }
table.items th:last-child { table.items th:last-child {
@@ -461,7 +463,7 @@ table.items tr.form td {
} }
/* 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 table styling: simplify selectors, deduplicate, remove non-font rem. */
table.items td.link a:hover, table.items td.link a:hover,
table.items td.link a:focus-visible, table.items td.link a:focus-visible,
table.items td.link a:hover:focus-visible { table.items td.link a:hover:focus-visible {
@@ -484,15 +486,13 @@ table.items td:not(:first-child),
color: var(--color-table-gray); color: var(--color-table-gray);
fill: var(--color-table-gray); fill: var(--color-table-gray);
} }
table.items td.hint {
color: var(--color-table-gray);
font-style: italic;
font-size: 0.8rem;
padding: 1em;
}
table.items svg { table.items svg {
height: 1.2rem; height: 1rem;
vertical-align: middle; vertical-align: middle;
width: 1rem;
}
table.items svg:last-child {
height: 1.2rem;
width: 1.2rem; width: 1.2rem;
} }
table.items td.svg { table.items td.svg {
@@ -505,7 +505,8 @@ table.items .button,
table.items button, table.items button,
table.items input[type=submit] { table.items input[type=submit] {
font-weight: normal; font-weight: normal;
padding: 0.3em; height: 100%;
padding: 0.4em;
} }
table.items input:not([type=submit]):not([type=checkbox]), table.items input:not([type=submit]):not([type=checkbox]),
table.items select, table.items select,
@@ -531,7 +532,6 @@ table.items select:focus-within,
table.items select:focus-visible { table.items select:focus-visible {
color: black; color: black;
} }
form a[name=cancel] { form a[name=cancel] {
border-color: var(--color-border-gray); border-color: var(--color-border-gray);
color: var(--color-nav-gray); color: var(--color-nav-gray);
@@ -549,28 +549,113 @@ form table.items td:first-child {
color: inherit; color: inherit;
} }
.centered { .centered {
margin: 0 auto; margin: 0 auto;
} }
.extendedright { .extendedright {
margin-right: auto; margin-right: auto;
} }
.hexpand {
width: 100%;
}
.hflex { .hflex {
display: flex; display: flex;
gap: 0.8em; gap: 0.8em;
} }
.hflex.reverse {
flex-direction: row-reverse;
}
.hflex.centered {
justify-content: center;
}
.hint {
color: var(--color-table-gray);
font-style: italic;
font-size: 0.9rem;
text-align: center;
}
.vflex { .vflex {
display: flex; display: flex;
gap: 0.8em; gap: 0.8em;
flex-direction: column; flex-direction: column;
} }
[disabled] { [disabled] {
/* label:has(input[disabled]) {
* TODO: disabled checkbox blue square focus removal; disabled label styling;
* focused label styling (currently only checkbox has focus)
* */
border-color: var(--color-border-gray) !important; border-color: var(--color-border-gray) !important;
color: var(--color-border-gray) !important; color: var(--color-border-gray) !important;
cursor: not-allowed; cursor: not-allowed;
fill: var(--color-border-gray) !important; fill: var(--color-border-gray) !important;
pointer-events: none; pointer-events: none;
} }
.unwrappable {
details {
align-content: center;
position: relative;
}
summary {
align-items: center;
color: var(--color-gray);
display: flex;
gap: 0.2em;
height: 100%;
white-space: nowrap; white-space: nowrap;
} }
summary::before {
background-color: #000;
content: "";
height: 1em;
mask-image: url('pictograms/chevron-down.svg');
mask-size: cover;
width: 1em;
}
summary:has(.button) {
padding-block: 0;
padding-inline-end: 0;
}
summary .button {
border: solid 1px var(--color-border-gray);
border-radius: inherit;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-style: none none none solid;
height: 100%;
}
summary span {
width: 100%;
}
details[open] summary::before {
transform: rotate(180deg);
}
summary::marker {
padding-left: 0.25em;
}
/* NOTE: use details[open]::details-content once widely available */
details[open] ul {
background: white;
border: solid 1px var(--color-border-gray);
border-radius: 0.25em;
box-shadow: 1px 1px 3px var(--color-border-gray);
margin: -1px 0 0 0;
padding-left: 0;
position: absolute;
width: 100%;
}
li:hover {
background-color: var(--color-focus-gray);
}
li label {
align-items: center;
display: flex;
line-height: 1.4em;
}
li input[type=checkbox] {
margin: 0 0.25em;
}
li::marker {
content: '';
}

View File

@@ -1,36 +1,11 @@
class MeasurementsController < ApplicationController class MeasurementsController < ApplicationController
before_action :find_quantity, only: [:new, :discard]
before_action :find_prev_quantities, only: [:new, :discard]
def index def index
@quantities = current_user.quantities.ordered @measurements = []
#@measurements = current_user.units.ordered.includes(:base, :subunits)
end end
def new def new
quantities = @quantities = current_user.quantities.ordered
case params[:scope]
when 'children'
@quantity.subquantities
when 'subtree'
@quantity.progenies
else
[@quantity]
end
quantities -= @prev_quantities
@readouts = current_user.readouts.build(quantities.map { |q| {quantity: q} })
@units = current_user.units.ordered
all_quantities = @prev_quantities + quantities
@common_ancestor = current_user.quantities
.common_ancestors(all_quantities.map(&:parent_id)).first
end
def discard
@prev_quantities -= [@quantity]
@common_ancestor = current_user.quantities
.common_ancestors(@prev_quantities.map(&:parent_id)).first
end end
def create def create
@@ -38,15 +13,4 @@ class MeasurementsController < ApplicationController
def destroy def destroy
end end
private
def find_quantity
@quantity = current_user.quantities.find_by!(id: params[:id])
end
def find_prev_quantities
prev_quantity_ids = params[:readouts]&.map { |r| r[:quantity_id] } || []
@prev_quantities = current_user.quantities.find(prev_quantity_ids)
end
end end

View File

@@ -0,0 +1,39 @@
class ReadoutsController < ApplicationController
before_action :find_quantities, only: [:new]
before_action :find_quantity, only: [:discard]
before_action :find_prev_quantities, only: [:new, :discard]
def new
@quantities -= @prev_quantities
# TODO: raise ParameterInvalid if new_quantities.empty?
@readouts = current_user.readouts.build(@quantities.map { |q| {quantity: q} })
@user_units = current_user.units.ordered
quantities = @prev_quantities + @quantities
@superquantity = current_user.quantities
.common_ancestors(quantities.map(&:parent_id)).first
end
def discard
@prev_quantities -= [@quantity]
@superquantity = current_user.quantities
.common_ancestors(@prev_quantities.map(&:parent_id)).first
end
private
def find_quantities
@quantities = current_user.quantities.find(params[:quantity])
end
def find_quantity
@quantity = current_user.quantities.find_by!(id: params[:id])
end
def find_prev_quantities
prev_quantity_ids = params[:readouts]&.map { |r| r[:quantity_id] } || []
@prev_quantities = current_user.quantities.find(prev_quantity_ids)
end
end

View File

@@ -1,6 +1,11 @@
class RegistrationsController < Devise::RegistrationsController class RegistrationsController < Devise::RegistrationsController
before_action :authenticate_user!, only: [:edit, :update, :destroy] before_action :authenticate_user!, only: [:edit, :update, :destroy]
def destroy
# TODO: Disallow/disable deletion for last admin account; update :edit view
super
end
protected protected
def update_resource(resource, params) def update_resource(resource, params)

View File

@@ -1,59 +1,84 @@
module ApplicationHelper module ApplicationHelper
# TODO: replace legacy content_tag with tag.tagname class LabeledFormBuilder < ActionView::Helpers::FormBuilder
class LabelledFormBuilder < ActionView::Helpers::FormBuilder (field_helpers - [:label, :hidden_field]).each do |selector|
(field_helpers - [:label]).each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {}) def #{selector}(method, options = {})
labelled_row_for(method, options) { super } labeled_field_for(method, options) { super }
end end
RUBY_EVAL RUBY_EVAL
end end
def select(method, choices = nil, options = {}, html_options = {}) def select(method, choices = nil, options = {}, html_options = {})
labelled_row_for(method, options) { super } labeled_field_for(method, options) { super }
end
def submit(value, options = {})
@template.content_tag :tr do
@template.content_tag :td, super, colspan: 2
end
end
def form_for(&block)
@template.content_tag(:table, class: "centered") { yield(self) } +
# Display leftover error messages (there shouldn't be any)
@template.content_tag(:div, @object&.errors.full_messages.join(@template.tag(:br)))
end end
private private
def labelled_row_for(method, options) def labeled_field_for(method, options)
@template.content_tag :tr do field = if options.delete(:readonly) then
@template.content_tag(:td, label_for(method, options), class: "unwrappable") + value = object.public_send(method)
@template.content_tag(:td, options.delete(:readonly) ? @object.public_send(method) : yield, value = @template.l(value) if value.respond_to?(:strftime)
@object&.errors[method].present? ? value ||= options[:placeholder]
{class: "error", data: {content: @object&.errors.delete(method).join(" and ")}} : else
{}) yield
end end
label_for(method, options) + field
end end
def label_for(method, options = {}) def label_for(method, options = {})
return '' if options[:label] == false
text = options.delete(:label)
text ||= @object.class.human_attribute_name(method).capitalize
classes = @template.class_names(required: options[:required], classes = @template.class_names(required: options[:required],
error: @object&.errors[method].present?) error: object.errors[method].present?)
label = label(method, "#{text}:", class: classes)
hint = options.delete(:hint)
label + (@template.tag(:br) + @template.content_tag(:em, hint) if hint) handler = {missing_interpolation_argument_handler: method(:interpolation_missing)}
# Label translation search order:
# controller.action.* => helpers.label.model.* => activerecord.attributes.model.*
# First 2 levels are translated recursively.
label(method, class: classes) do |builder|
translation = I18n.config.with(**handler) { deep_translate(method, **options) }
translation.presence || "#{builder.translation}:"
end end
end end
def labelled_form_for(record, options = {}, &block) def interpolation_missing(key, values, string)
options = options.deep_merge(builder: LabelledFormBuilder, data: {turbo: false}) @template.instance_values[key.to_s] || deep_translate(key, **values)
form_for(record, **options) { |f| f.form_for(&block) } end
# Extension to label text translation:
# * recursive translation,
# * interpolation of (_relative_) translation key names and instance variables,
# * pluralization based on any interpolable value
# TODO: add unit tests for the above
def deep_translate(key, **options)
options[:default] = [
:".#{key}",
:"helpers.label.#{@object_name}.#{key}_html",
:"helpers.label.#{@object_name}.#{key}",
""
]
# At least 1 interpolation key is required for #translate to act on
# missing interpolation arguments (i.e. call custom handler).
# Also setting `key` to nil avoids recurrent translation.
options[key] = nil
@template.t(".#{key}_html", **options) do |translation, resolved_key|
if translation.is_a?(Array) && (count = translation.to_h[:count])
@template.t(resolved_key, count: I18n.interpolate(count, **options), **options)
else
translation
end
end
end
end
def labeled_form_for(record, options = {}, &block)
extra_options = {builder: LabeledFormBuilder,
data: {turbo: false},
html: {class: 'labeled-form'}}
options = options.deep_merge(extra_options) do |key, left, right|
key == :class ? class_names(left, right) : right
end
form_for(record, **options, &block)
end end
class TabularFormBuilder < ActionView::Helpers::FormBuilder class TabularFormBuilder < ActionView::Helpers::FormBuilder
@@ -75,12 +100,11 @@ module ApplicationHelper
end end
def number_field(method, options = {}) def number_field(method, options = {})
value = object.public_send(method) attr_type = object.type_for_attribute(method)
if value.is_a?(BigDecimal) if attr_type.type == :decimal
options[:value] = value.to_scientific options[:value] = object.public_send(method)&.to_scientific
type = object.class.type_for_attribute(method) options[:step] ||= BigDecimal(10).power(-attr_type.scale)
options[:step] ||= BigDecimal(10).power(-type.scale) options[:max] ||= BigDecimal(10).power(attr_type.precision - attr_type.scale) -
options[:max] ||= BigDecimal(10).power(type.precision - type.scale) -
options[:step] options[:step]
options[:min] = options[:min] == :step ? options[:step] : options[:min] options[:min] = options[:min] == :step ? options[:step] : options[:min]
options[:min] ||= -options[:max] options[:min] ||= -options[:max]
@@ -112,6 +136,7 @@ module ApplicationHelper
# and the first input gets focus. # and the first input gets focus.
record_object, options = nil, record_object if record_object.is_a?(Hash) record_object, options = nil, record_object if record_object.is_a?(Hash)
options.merge!(builder: TabularFormBuilder, skip_default_ids: true) options.merge!(builder: TabularFormBuilder, skip_default_ids: true)
# TODO: set error message with setCustomValidity instead of rendering to flash?
render_errors(record_object || record_name) render_errors(record_object || record_name)
fields_for(record_name, record_object, **options, &block) fields_for(record_name, record_object, **options, &block)
end end
@@ -123,7 +148,7 @@ module ApplicationHelper
end end
def svg_tag(source, label = nil, options = {}) def svg_tag(source, label = nil, options = {})
svg_tag = content_tag :svg, options do svg_tag = tag.svg(options) do
tag.use(href: "#{image_path(source + ".svg")}#icon") tag.use(href: "#{image_path(source + ".svg")}#icon")
end end
label.blank? ? svg_tag : svg_tag + tag.span(label) label.blank? ? svg_tag : svg_tag + tag.span(label)
@@ -171,17 +196,23 @@ module ApplicationHelper
def image_link_to_unless_current(name, image = nil, options = nil, html_options = {}) def image_link_to_unless_current(name, image = nil, options = nil, html_options = {})
name, html_options = link_or_button_options(:link, name, image, html_options) name, html_options = link_or_button_options(:link, name, image, html_options)
html_options = html_options.deep_merge DISABLED_ATTRIBUTES if current_page?(options) # NOTE: Starting from Rails 8.1.0, below condition can be replaced with:
# current_page?(options, method: [:get, :post])
if request.path == url_for(options)
html_options = html_options.deep_merge DISABLED_ATTRIBUTES
end
link_to name, options, html_options link_to name, options, html_options
end end
def render_errors(records) def render_errors(records)
flash[:alert] ||= [] # Conversion of flash to Array only required because of Devise
flash[:alert] = Array(flash[:alert])
Array(records).each { |record| flash[:alert] += record.errors.full_messages } Array(records).each { |record| flash[:alert] += record.errors.full_messages }
end end
def render_flash_messages def render_flash_messages
flash.map do |entry, messages| flash.map do |entry, messages|
# Conversion of flash to Array only required because of Devise
Array(messages).map do |message| Array(messages).map do |message|
tag.div class: "flash #{entry}" do tag.div class: "flash #{entry}" do
tag.div(sanitize(message)) + tag.button(sanitize("&times;"), tabindex: -1, tag.div(sanitize(message)) + tag.button(sanitize("&times;"), tabindex: -1,
@@ -204,8 +235,6 @@ module ApplicationHelper
private private
def link_or_button_options(type, name, image = nil, html_options) def link_or_button_options(type, name, image = nil, html_options)
name = svg_tag("pictograms/#{image}", name) if image
html_options[:class] = class_names( html_options[:class] = class_names(
html_options[:class], html_options[:class],
'button', 'button',
@@ -216,9 +245,10 @@ module ApplicationHelper
html_options[:onclick] = "return confirm('\#{html_options[:onclick][:confirm]}');" html_options[:onclick] = "return confirm('\#{html_options[:onclick][:confirm]}');"
end end
if type == :link && !(html_options[:onclick] || html_options.dig(:data, :turbo_stream)) link_is_local = html_options[:onclick] || html_options.dig(:data, :turbo_stream)
name += '...' name = name.to_s
end name += '...' if type == :link && !link_is_local
name = svg_tag("pictograms/#{image}", name) if image
[name, html_options] [name, html_options]
end end

View File

@@ -1,2 +1,11 @@
module QuantitiesHelper module QuantitiesHelper
def quantities_check_boxes
# Closing <details> on focusout event depends on relatedTarget for internal
# actions being non-null. To ensure this, all top-layer elements of
# ::details-content must accept focus, e.g. <label> needs tabindex="-1" */
collection_check_boxes(nil, :quantity, @quantities, :id, :to_s_with_depth,
include_hidden: false) do |b|
content_tag :li, b.label(tabindex: -1) { b.check_box + b.text }
end
end
end end

View File

@@ -9,31 +9,70 @@ function showPage(event) {
} }
document.addEventListener('turbo:load', showPage) document.addEventListener('turbo:load', showPage)
function detailsChange(event) {
var target = event.currentTarget
var count = target.querySelectorAll('input:checked:not([disabled])').length
var span = target.querySelector('summary > span')
var button = target.querySelector('button')
if (count > 0) {
span.textContent = count + ' selected';
Turbo.StreamElement.prototype.enableElement(button)
} else {
span.textContent = span.getAttribute('data-prompt')
Turbo.StreamElement.prototype.disableElement(button)
}
}
window.detailsChange = detailsChange
/* Close open <details> when focus lost */
function detailsClose(event) {
if (!event.relatedTarget ||
event.relatedTarget.closest("details") != event.currentTarget) {
event.currentTarget.removeAttribute("open")
}
}
window.detailsClose = detailsClose
window.detailsObserver = new MutationObserver((mutations) => {
mutations[0].target.dispatchEvent(new Event('change', {bubbles: true}))
});
/* Turbo stream actions */ /* Turbo stream actions */
Turbo.StreamElement.prototype.disableElement = function(element) {
element.setAttribute("disabled", "disabled")
element.setAttribute("aria-disabled", "true")
element.setAttribute("tabindex", "-1")
}
Turbo.StreamActions.disable = function() {
this.targetElements.forEach((e) => { this.disableElement(e) })
}
Turbo.StreamElement.prototype.enableElement = function(element) { Turbo.StreamElement.prototype.enableElement = function(element) {
element.removeAttribute("disabled") element.removeAttribute("disabled")
element.removeAttribute("aria-disabled") element.removeAttribute("aria-disabled")
// 'tabindex' is not used explicitly, so removing it is safe // Assume 'tabindex' is not used explicitly, so removing it is safe
element.removeAttribute("tabindex") element.removeAttribute("tabindex")
} }
Turbo.StreamActions.disable = function() {
this.targetElements.forEach((e) => {
e.setAttribute("disabled", "disabled")
e.setAttribute("aria-disabled", "true")
e.setAttribute("tabindex", "-1")
})
}
Turbo.StreamActions.enable = function() { Turbo.StreamActions.enable = function() {
this.targetElements.forEach((e) => { this.enableElement(e) }) this.targetElements.forEach((e) => { this.enableElement(e) })
} }
/* TODO: change to visibility = collapse to avoid width change? */
Turbo.StreamActions.hide = function() { Turbo.StreamActions.hide = function() {
this.targetElements.forEach((e) => { e.style.display = "none" }) this.targetElements.forEach((e) => { e.style.display = "none" })
} }
Turbo.StreamActions.show = function() {
this.targetElements.forEach((e) => { e.style.removeProperty("display") })
}
/*
Turbo.StreamActions.collapse = function() {
this.targetElements.forEach((e) => { e.style.visibility = "collapse" })
}
*/
Turbo.StreamActions.close_form = function() { Turbo.StreamActions.close_form = function() {
this.targetElements.forEach((e) => { this.targetElements.forEach((e) => {
/* Move focus if there's no focus or focus inside form being closed */ /* Move focus if there's no focus or focus inside form being closed */
@@ -54,28 +93,63 @@ Turbo.StreamActions.close_form = function() {
}) })
} }
Turbo.StreamActions.unselect = function() {
this.targetElements.forEach((e) => {
e.checked = false
this.enableElement(e)
})
}
function formProcessKey(event) {
switch (event.key) {
case "Escape":
event.currentTarget.querySelector("a[name=cancel]").click()
break
case "Enter":
event.currentTarget.querySelector("button[name=button]").click()
event.preventDefault()
break
}
}
window.formProcessKey = formProcessKey
function detailsProcessKey(event) {
// TODO: up/down arrows to move focus to prev/next line
switch (event.key) {
case "Escape":
if (event.currentTarget.hasAttribute("open")) {
event.currentTarget.removeAttribute("open")
event.stopPropagation()
}
break
case "Enter":
var button = event.currentTarget.querySelector("button:not([disabled])")
if (button) {
button.click()
// Autofocus won't be respected unless target is blurred
event.target.blur()
event.preventDefault()
event.stopPropagation()
}
break
}
}
window.detailsProcessKey = detailsProcessKey;
/* Items table drag and drop support */ /* Items table drag and drop support */
function processKey(event) { var lastEnterTime
if (event.key == "Escape") {
event.currentTarget.querySelector("a[name=cancel]").click();
}
}
window.processKey = processKey;
var lastEnterTime;
function dragStart(event) { function dragStart(event) {
lastEnterTime = event.timeStamp; lastEnterTime = event.timeStamp
var row = event.currentTarget; var row = event.currentTarget
row.closest("table").querySelectorAll("thead tr").forEach((tr) => { row.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden"); tr.toggleAttribute("hidden")
}); })
event.dataTransfer.setData("text/plain", row.getAttribute("data-drag-path")); event.dataTransfer.setData("text/plain", row.getAttribute("data-drag-path"))
var rowRectangle = row.getBoundingClientRect(); var rowRectangle = row.getBoundingClientRect()
event.dataTransfer.setDragImage(row, event.x - rowRectangle.left, event.y - rowRectangle.top); event.dataTransfer.setDragImage(row, event.x - rowRectangle.left, event.y - rowRectangle.top)
event.dataTransfer.dropEffect = "move"; event.dataTransfer.dropEffect = "move"
} }
window.dragStart = dragStart; window.dragStart = dragStart
/* /*
* Drag tracking assumptions (based on FF 122.0 experience): * Drag tracking assumptions (based on FF 122.0 experience):
@@ -87,44 +161,44 @@ window.dragStart = dragStart;
* and outside. This should probably be fixed in browser, than patched here. * and outside. This should probably be fixed in browser, than patched here.
*/ */
function dragEnter(event) { function dragEnter(event) {
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id); //console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id)
dragLeave(event); dragLeave(event)
lastEnterTime = event.timeStamp; lastEnterTime = event.timeStamp
const id = event.currentTarget.getAttribute("data-drop-id"); const id = event.currentTarget.getAttribute("data-drop-id")
document.getElementById(id).classList.add("dropzone"); document.getElementById(id).classList.add("dropzone")
} }
window.dragEnter = dragEnter; window.dragEnter = dragEnter
function dragOver(event) { function dragOver(event) {
event.preventDefault(); event.preventDefault()
} }
window.dragOver = dragOver; window.dragOver = dragOver
function dragLeave(event) { function dragLeave(event) {
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id); //console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id)
// Leave has been accounted for by Enter at the same timestamp, processed earlier // Leave has been accounted for by Enter at the same timestamp, processed earlier
if (event.timeStamp <= lastEnterTime) return; if (event.timeStamp <= lastEnterTime) return
event.currentTarget.closest("table").querySelectorAll(".dropzone").forEach((tr) => { event.currentTarget.closest("table").querySelectorAll(".dropzone").forEach((tr) => {
tr.classList.remove("dropzone"); tr.classList.remove("dropzone")
}); })
} }
window.dragLeave = dragLeave; window.dragLeave = dragLeave
function dragEnd(event) { function dragEnd(event) {
dragLeave(event); dragLeave(event)
event.currentTarget.closest("table").querySelectorAll("thead tr").forEach((tr) => { event.currentTarget.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden"); tr.toggleAttribute("hidden")
}); })
} }
window.dragEnd = dragEnd; window.dragEnd = dragEnd
function drop(event) { function drop(event) {
event.preventDefault(); event.preventDefault()
var params = new URLSearchParams(); var params = new URLSearchParams()
var id_param = event.currentTarget.getAttribute("data-drop-id-param"); var id_param = event.currentTarget.getAttribute("data-drop-id-param")
var id = event.currentTarget.getAttribute("data-drop-id").split("_").pop(); var id = event.currentTarget.getAttribute("data-drop-id").split("_").pop()
params.append(id_param, id); params.append(id_param, id)
fetch(event.dataTransfer.getData("text/plain"), { fetch(event.dataTransfer.getData("text/plain"), {
body: params, body: params,
@@ -138,4 +212,4 @@ function drop(event) {
.then(response => response.text()) .then(response => response.text())
.then(html => Turbo.renderStreamMessage(html)) .then(html => Turbo.renderStreamMessage(html))
} }
window.drop = drop; window.drop = drop

View File

@@ -0,0 +1,3 @@
class Measurement
include ActiveModel::Model
end

View File

@@ -100,6 +100,11 @@ class Quantity < ApplicationRecord
name name
end end
def to_s_with_depth
# em space, U+2003
'' * depth + name
end
def destroyable? def destroyable?
subquantities.empty? subquantities.empty?
end end

View File

@@ -3,7 +3,8 @@ class Unit < ApplicationRecord
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", inverse_of: :base, dependent: :restrict_with_error 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_id == base.user_id errors.add(:base, :user_mismatch) unless user_id == base.user_id

View File

@@ -12,10 +12,10 @@ class User < ApplicationRecord
disabled: 0, # administratively disallowed to sign in disabled: 0, # administratively disallowed to sign in
}, default: :active, validate: true }, default: :active, validate: true
has_many :readouts, dependent: :destroy has_many :readouts, dependent: :delete_all
accepts_nested_attributes_for :readouts accepts_nested_attributes_for :readouts
has_many :quantities, dependent: :destroy has_many :quantities, dependent: :delete_all
has_many :units, dependent: :destroy has_many :units, dependent: :delete_all
validates :email, presence: true, uniqueness: true, validates :email, presence: true, uniqueness: true,
length: {maximum: type_for_attribute(:email).limit} length: {maximum: type_for_attribute(:email).limit}

View File

@@ -1,16 +1,17 @@
<div class="rightside buttongrid"> <div class="rightside-area buttongrid">
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<%# TODO: implement Import all %> <%# TODO: implement Import all %>
<%#= image_button_to t('.import_all'), 'download-multiple-outline', <%#= image_button_to t('.import_all'), 'download-multiple-outline',
import_all_default_units_path, data: {turbo_stream: true} %> import_all_default_units_path, data: {turbo_stream: true} %>
<% end %> <% end %>
<%= image_link_to t('.back'), 'arrow-left-bold-outline', units_path, class: 'tools' %> <%= image_link_to t('.back'), 'arrow-left-bold-outline', units_path,
class: 'tools-area' %>
</div> </div>
<table class="main items"> <table class="main-area items">
<thead> <thead>
<tr> <tr>
<th><%= User.human_attribute_name(:symbol).capitalize %></th> <th><%= Unit.human_attribute_name(:symbol) %></th>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<th><%= t '.actions' %></th> <th><%= t '.actions' %></th>
<% end %> <% end %>

View File

@@ -47,7 +47,7 @@
<%= render_flash_messages %> <%= render_flash_messages %>
</div> </div>
<%# Allow overwriting/clearing navigation menu for some views %> <%# Allows overwriting/clearing navigation menu for some views %>
<nav class="navigation"> <nav class="navigation">
<%= content_for(:navigation) || (navigation_menu if user_signed_in?) %> <%= content_for(:navigation) || (navigation_menu if user_signed_in?) %>
</nav> </nav>

View File

@@ -1,25 +1,36 @@
<% @readouts.each do |readout| %> <%= tabular_form_with model: Measurement.new, id: :measurement_form,
<%= tabular_fields_for 'readouts[]', readout do |form| %> class: 'topside-area vflex', html: {onkeydown: 'formProcessKey(event)'} do |form| %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout), <table class="items centered">
onkeydown: 'processKey(event)' do %> <tbody id="readouts"></tbody>
<%= tag.td id: dom_id(readout.quantity, nil, :pathname) do %> </table>
<%= readout.quantity.relative_pathname(@common_ancestor) %>
<%end%>
<td>
<%= form.number_field :value, required: true, autofocus: true, size: 10 %>
</td>
<td>
<%= form.select :unit_id, options_from_collection_for_select(
@units, :id, ->(u){ sanitize('&emsp;'*(u.base_id ? 1 : 0) + u.symbol) }
) %>
</td>
<td class="actions"> <div class="hflex">
<%= image_button_tag '', 'delete-outline', class: 'dangerous', <%# TODO: right-click selection %>
formaction: discard_new_measurement_path(readout.quantity), <details id="quantity_select" class="hexpand" open
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %> onkeydown="detailsProcessKey(event)">
<%= form.hidden_field :quantity_id %> <summary autofocus>
</td> <!-- TODO: Set content with CSS when span empty to avoid duplication -->
<% end %> <span data-prompt="<%= t('.select_quantity') %>">
<% end %> <%= t('.select_quantity') %>
</span>
<%= image_button_tag t(:apply), "update", name: nil, disabled: true,
formaction: new_readout_path, formmethod: :get, formnovalidate: true,
data: {turbo_stream: true} %>
</summary>
<ul><%= quantities_check_boxes %></ul>
</details>
<%= form.button id: :create_measurement_button, disabled: true -%>
</div>
<div class="hflex reverse">
<%= image_link_to t(:cancel), "close-outline", measurements_path, name: :cancel,
class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</div>
<% end %> <% end %>
<script>
quantity_select.addEventListener('focusout', detailsClose)
quantity_select.addEventListener('change', detailsChange)
detailsObserver.observe(quantity_select.querySelector('ul'),
{subtree: true, attributeFilter: ['disabled']})
</script>

View File

@@ -1,2 +1,4 @@
<%= turbo_stream.update :new_readouts_form %>
<%= turbo_stream.update :flashes %> <%= turbo_stream.update :flashes %>
<%= turbo_stream.remove :measurement_form %>
<%= turbo_stream.show :no_items -%>
<%= turbo_stream.enable :new_measurement_link -%>

View File

@@ -1,16 +0,0 @@
<%= tabular_fields_for Readout.new do |form| %>
<fieldset>
<legend>
<%= tag.span id: :new_readouts_form_legend %>
<%= image_link_to '', "close-outline", measurements_path, name: :cancel,
class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</legend>
<table class="items centered">
<tbody id="readouts">
<tr id="new_readouts_actions">
<td colspan="4"><div class="actions centered"><%= form.button %></div></td>
</tr>
</tbody>
</table>
</fieldset>
<% end %>

View File

@@ -1,7 +0,0 @@
<%= turbo_stream.update(:new_readouts_form_legend) { @common_ancestor&.pathname } %>
<% @prev_quantities.each do |pq| %>
<%= turbo_stream.update dom_id(pq, nil, :pathname) do %>
<%= pq.relative_pathname(@common_ancestor) %>
<% end %>
<% end %>

View File

@@ -1,2 +0,0 @@
<%= turbo_stream.remove dom_id(@quantity, :new, :readout) %>
<%= render partial: 'form_repath' %>

View File

@@ -1,20 +1,14 @@
<div class="topside vflex">
<% if current_user.at_least(:active) %>
<%# TODO: show hint when no quantities/units defined %> <%# TODO: show hint when no quantities/units defined %>
<%= tabular_form_with url: new_measurement_path, <div class="rightside-area buttongrid">
html: {id: :new_readouts_form} do |f| %> <% if current_user.at_least(:active) %>
<% end %> <%= image_link_to t('.new_measurement'), 'plus-outline', new_measurement_path,
<div class="hflex"> id: :new_measurement_link, onclick: 'this.blur();',
<%= select_tag :id, options_from_collection_for_select( data: {turbo_stream: true} %>
@quantities, :id, ->(q){ sanitize('&emsp;' * q.depth + q.name) }
), form: :new_readouts_form %>
<% common_options = {form: :new_readouts_form, formmethod: :get,
formnovalidate: true, data: {turbo_stream: true}} %>
<%= image_button_tag t('.new_quantity'), 'plus-outline', **common_options -%>
<%= image_button_tag t('.new_children'), 'plus-multiple-outline',
formaction: new_measurement_path(:children), **common_options -%>
<%= image_button_tag t('.new_subtree'), 'plus-multiple-outline',
formaction: new_measurement_path(:subtree), **common_options -%>
</div>
<% end %> <% end %>
</div> </div>
<table class="main-area">
<tbody id="measurements">
<%= render(@measurements) || render_no_items %>
</tbody>
</table>

View File

@@ -1,9 +1,5 @@
<%= turbo_stream.update :new_readouts_form do %> <%= turbo_stream.disable :new_measurement_link -%>
<%= render partial: 'form_frame' %> <%= turbo_stream.hide :no_items -%>
<% end if @prev_quantities.empty? %> <%= turbo_stream.append_all 'body' do %>
<%= render partial: 'form_repath' %>
<%= turbo_stream.before :new_readouts_actions do %>
<%= render partial: 'form' %> <%= render partial: 'form' %>
<% end %> <% end %>

View File

@@ -1,5 +1,5 @@
<%= tabular_fields_for @quantity, form: form_tag do |form| %> <%= tabular_fields_for @quantity, form: form_tag do |form| %>
<%- tag.tr id: row, class: "form", onkeydown: "processKey(event)", <%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {link: link, form: form_tag, hidden_row: hidden_row} do %> data: {link: link, form: form_tag, hidden_row: hidden_row} do %>
<td style="--depth:<%= @quantity.depth %>"> <td style="--depth:<%= @quantity.depth %>">

View File

@@ -1,19 +1,20 @@
<div class="rightside buttongrid"> <div class="rightside-area buttongrid">
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<%= image_link_to t('.new_quantity'), 'plus-outline', new_quantity_path, <%= image_link_to t('.new_quantity'), 'plus-outline', new_quantity_path,
id: dom_id(Quantity, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %> id: dom_id(Quantity, :new, :link), onclick: 'this.blur();',
data: {turbo_stream: true} %>
<% end %> <% end %>
<%#= image_link_to t('.import_quantities'), 'download-outline', default_quantities_path, <%#= image_link_to t('.import_quantities'), 'download-outline', default_quantities_path,
class: 'tools' %> class: 'tools-area' %>
</div> </div>
<%= tag.div class: 'main', id: :quantity_form %> <%= tag.div class: 'main-area', id: :quantity_form %>
<table class="main items"> <table class="main-area items">
<thead> <thead>
<tr> <tr>
<th><%= Quantity.human_attribute_name(:name).capitalize %></th> <th><%= Quantity.human_attribute_name(:name) %></th>
<th><%= Quantity.human_attribute_name(:description).capitalize %></th> <th><%= Quantity.human_attribute_name(:description) %></th>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<th><%= t :actions %></th> <th><%= t :actions %></th>
<th></th> <th></th>

View File

@@ -0,0 +1,25 @@
<%# TODO: add readout reordering by dragging %>
<%= tabular_fields_for 'readouts[]', readout do |form| %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout) do %>
<td class="actions">
<%# TODO: change to _link_ after giving up displaying relative paths %>
<%= image_button_tag '', 'delete-outline', class: 'dangerous', name: nil,
formaction: discard_readouts_path(readout.quantity),
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>
</td>
<td>
<%= readout.quantity.relative_pathname(@superquantity) %>
</td>
<td>
<%= form.number_field :value, required: true,
size: readout.type_for_attribute(:value).precision / 2,
autofocus: readout_counter == 0 %>
</td>
<td>
<%= form.hidden_field :quantity_id %>
<%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) },
{prompt: t('.select_unit'), disabled: '', selected: ''}, required: true %>
</td>
<% end %>
<% end %>

View File

@@ -0,0 +1,7 @@
<%= turbo_stream.update(:measurement_form_legend) { @superquantity&.pathname } %>
<% @prev_quantities.each do |pq| %>
<%= turbo_stream.update dom_id(pq, nil, :pathname) do %>
<%= pq.relative_pathname(@superquantity) %>
<% end %>
<% end %>

View File

@@ -0,0 +1,4 @@
<%= turbo_stream.disable :create_measurement_button if @prev_quantities.one? %>
<%= turbo_stream.remove dom_id(@quantity, :new, :readout) %>
<%= render partial: 'form_repath' %>
<%= turbo_stream.unselect dom_id(@quantity) %>

View File

@@ -0,0 +1,8 @@
<% @readouts.each do |r| %>
<%= turbo_stream.disable dom_id(r.quantity) %>
<% end %>
<%= render partial: 'form_repath' %>
<%= turbo_stream.append :readouts do %>
<%= render partial: 'form', collection: @readouts, as: :readout %>
<% end %>
<%= turbo_stream.enable :create_measurement_button if @prev_quantities.empty? %>

View File

@@ -1,5 +1,5 @@
<%= tabular_fields_for @unit, form: form_tag do |form| %> <%= tabular_fields_for @unit, form: form_tag do |form| %>
<%- tag.tr id: row, class: "form", onkeydown: "processKey(event)", <%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {link: link, form: form_tag, hidden_row: hidden_row} do %> data: {link: link, form: form_tag, hidden_row: hidden_row} do %>
<td style="--depth:<%= @unit.base_id? ? 1 : 0 %>"> <td style="--depth:<%= @unit.base_id? ? 1 : 0 %>">

View File

@@ -1,19 +1,20 @@
<div class="rightside buttongrid"> <div class="rightside-area buttongrid">
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<%= image_link_to t('.new_unit'), 'plus-outline', new_unit_path, <%= image_link_to t('.new_unit'), 'plus-outline', new_unit_path,
id: dom_id(Unit, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %> id: dom_id(Unit, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>
<% end %> <% end %>
<%= image_link_to t('.import_units'), 'download-outline', default_units_path, class: 'tools' %> <%= image_link_to t('.import_units'), 'download-outline', default_units_path,
class: 'tools-area' %>
</div> </div>
<%= tag.div id: :unit_form %> <%= tag.div id: :unit_form %>
<table class="main items"> <table class="main-area items">
<thead> <thead>
<tr> <tr>
<th><%= User.human_attribute_name(:symbol).capitalize %></th> <th><%= Unit.human_attribute_name(:symbol) %></th>
<th><%= User.human_attribute_name(:description).capitalize %></th> <th><%= Unit.human_attribute_name(:description) %></th>
<th><%= User.human_attribute_name(:multiplier).capitalize %></th> <th><%= Unit.human_attribute_name(:multiplier) %></th>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<th><%= t :actions %></th> <th><%= t :actions %></th>
<th></th> <th></th>

View File

@@ -1,8 +1,9 @@
<div class="main"> <%= labeled_form_for resource, url: user_confirmation_path,
<%= labelled_form_for resource, url: user_confirmation_path do |f| %> html: {class: 'main-area'} do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, autocomplete: "email",
value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email) %> <%= f.email_field :email, required: true, size: 30, autofocus: true,
autocomplete: 'email', value:
resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email %>
<%= f.submit t(:resend_confirmation) %> <%= f.submit t(:resend_confirmation) %>
<% end %> <% end %>
</div>

View File

@@ -1,12 +1,10 @@
<table class="main items" id="users"> <table class="main-area items" id="users">
<thead> <thead>
<tr> <tr>
<th><%= User.human_attribute_name(:email).capitalize %></th> <th><%= User.human_attribute_name(:email) %></th>
<th><%= User.human_attribute_name(:status).capitalize %></th> <th><%= User.human_attribute_name(:status) %></th>
<th><%= User.human_attribute_name(:confirmed_at).capitalize %></th> <th><%= User.human_attribute_name(:confirmed_at) %></th>
<th> <th><%= User.human_attribute_name(:created_at) %>&nbsp;<sup>(UTC)</sup></th>
<%= User.human_attribute_name(:created_at).capitalize %>&nbsp;<sup>UTC</sup>
</th>
<th><%= t :actions %></th> <th><%= t :actions %></th>
</tr> </tr>
</thead> </thead>
@@ -19,18 +17,18 @@
<%= user.status %> <%= user.status %>
<% else %> <% else %>
<%= form_for user do |u| %> <%= form_for user do |u| %>
<%= u.select :status, User.statuses.keys, {}, autocomplete: "off", <%= u.select :status, User.statuses.keys, {}, autocomplete: 'off',
onchange: "this.form.requestSubmit();" %> onchange: 'this.form.requestSubmit();' %>
<% end %> <% end %>
<% end %> <% end %>
</td> </td>
<td class="svg"> <td class="svg">
<%= svg_tag "pictograms/checkbox-marked-outline" if user.confirmed_at.present? %> <%= svg_tag 'pictograms/checkbox-marked-outline' if user.confirmed_at.present? %>
</td> </td>
<td><%= user.created_at.to_fs(:db_without_sec) %></td> <td><%= l user.created_at, format: :without_tz %></td>
<td class="actions"> <td class="actions">
<% if allow_disguise?(user) %> <% if allow_disguise?(user) %>
<%= image_link_to t(".disguise"), "incognito", disguise_user_path(user) %> <%= image_link_to t('.disguise'), 'incognito', disguise_user_path(user) %>
<% end %> <% end %>
</td> </td>
</tr> </tr>

View File

@@ -1,5 +1,5 @@
<p>Welcome <%= @email %>!</p> <p>Welcome <%= @email %>!</p>
<p>You can confirm your account email through the link below:</p> <p>You can confirm your account email through the link below:</p>
<!-- FIXME: is confirmation_url valid route prefix? -->
<p><%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %></p> <p><%= link_to 'Confirm my account', confirmation_url(@resource, confirmation_token: @token) %></p>

View File

@@ -1,13 +1,12 @@
<div class="main"> <%= labeled_form_for resource, url: user_password_path,
<%= labelled_form_for resource, url: user_password_path, html: {method: :put} do |f| %> html: {method: :put, class: 'main-area'} do |f| %>
<%= f.hidden_field :reset_password_token, label: false %>
<%= f.password_field :password, label: t(".new_password"), required: true, size: 30, <%= f.hidden_field :reset_password_token %>
minlength: @minimum_password_length, autofocus: true, autocomplete: "new-password",
hint: t("users.minimum_password_length", count: @minimum_password_length) %>
<%= f.password_field :password_confirmation, label: t(".password_confirmation"),
required: true, size: 30, minlength: @minimum_password_length, autocomplete: "off" %>
<%= f.submit t(".update_password") %> <%= f.password_field :password, required: true, size: 30, autofocus: true,
minlength: @minimum_password_length, autocomplete: 'new-password' %>
<%= f.password_field :password_confirmation, required: true, size: 30,
minlength: @minimum_password_length, autocomplete: 'off' %>
<%= f.submit t('.update_password') %>
<% end %> <% end %>
</div>

View File

@@ -1,7 +1,8 @@
<div class="main"> <%= labeled_form_for resource, url: user_password_path,
<%= labelled_form_for resource, url: user_password_path do |f| %> html: {class: 'main-area'} do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, autocomplete: "email" %>
<%= f.email_field :email, required: true, size: 30, autofocus: true,
autocomplete: 'email' %>
<%= f.submit t(:recover_password) %> <%= f.submit t(:recover_password) %>
<% end %> <% end %>
</div>

View File

@@ -1,31 +1,30 @@
<% content_for :navigation, flush: true do %> <% content_for :navigation, flush: true do %>
<%= link_to svg_tag("pictograms/arrow-left-bold-outline", t(:back)), <%= link_to svg_tag('pictograms/arrow-left-bold-outline', t(:back)),
request.referer.present? ? :back : root_path, class: 'tab' %> request.referer.present? ? :back : root_path, class: 'tab' %>
<% end %> <% end %>
<div class="rightside buttongrid"> <div class="rightside-area buttongrid">
<%= image_button_to t(".delete"), "account-remove-outline", user_registration_path, <%#= TODO: Disallow/disable deletion for last admin account, image_button_to_if %>
form_class: 'tools', method: :delete, data: {turbo: false}, <%= image_button_to t('.delete'), 'account-remove-outline', user_registration_path,
onclick: {confirm: t(".confirm_delete")} %> form_class: 'tools-area', method: :delete, data: {turbo: false},
onclick: {confirm: t('.confirm_delete')} %>
</div> </div>
<%= labelled_form_for resource, url: registration_path(resource), <%= labeled_form_for resource, url: registration_path(resource),
html: {method: :patch, class: 'main'} do |f| %> html: {method: :patch, class: 'main-area'} do |f| %>
<%= f.email_field :email, size: 30, autofocus: true, autocomplete: "off" %>
<% if f.object.pending_reconfirmation? %> <%= f.email_field :email, size: 30, autofocus: true, autocomplete: 'off' %>
<%= f.text_field :unconfirmed_email, readonly: true, tabindex: -1, <% if resource.pending_reconfirmation? %>
hint: t(".unconfirmed_email_hint", <%= f.text_field :unconfirmed_email, readonly: true,
timestamp: f.object.confirmation_sent_at.to_fs(:db_without_sec)) %> confirmation_sent_at: l(resource.confirmation_sent_at) %>
<% end %> <% end %>
<%= f.select :status, User.statuses, readonly: true %> <%= f.select :status, User.statuses, readonly: true %>
<%= f.password_field :password, label: t(".new_password"), size: 30, <%= f.password_field :password, size: 30, autocomplete: 'new-password',
minlength: @minimum_password_length, autocomplete: "new-password", minlength: @minimum_password_length %>
hint: t(".blank_password_hint_html", <%= f.password_field :password_confirmation, size: 30, autocomplete: 'off',
subhint: t("users.minimum_password_length", count: @minimum_password_length)) %> minlength: @minimum_password_length %>
<%= f.password_field :password_confirmation, label: t(".password_confirmation"),
size: 30, minlength: @minimum_password_length, autocomplete: "off" %>
<%= f.submit t(".update") %> <%= f.submit %>
<% end %> <% end %>

View File

@@ -1,16 +1,16 @@
<div class="main"> <div class="main-area">
<%= labelled_form_for resource, url: user_registration_path do |f| %> <%= labeled_form_for resource, url: user_registration_path do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, autocomplete: "email" %> <%= f.email_field :email, required: true, size: 30, autofocus: true,
autocomplete: 'email' %>
<%= f.password_field :password, required: true, size: 30, <%= f.password_field :password, required: true, size: 30,
minlength: @minimum_password_length, autocomplete: "new-password", minlength: @minimum_password_length, autocomplete: 'new-password' %>
hint: t("users.minimum_password_length", count: @minimum_password_length) %> <%= f.password_field :password_confirmation, required: true, size: 30,
<%= f.password_field :password_confirmation, label: t(".password_confirmation"), minlength: @minimum_password_length, autocomplete: 'off' %>
required: true, size: 30, minlength: @minimum_password_length, autocomplete: "off" %>
<%= f.submit t(:register) %> <%= f.submit t(:register) %>
<% end %> <% end %>
<%= content_tag :p, t(:or), style: "text-align: center;" %> <%= content_tag :p, t(:or), style: 'text-align: center;' %>
<%= image_link_to t(:resend_confirmation), "email-sync-outline", new_user_confirmation_path, <%= image_link_to t(:resend_confirmation), 'email-sync-outline',
class: "centered" %> new_user_confirmation_path, class: 'centered' %>
</div> </div>

View File

@@ -1,17 +1,18 @@
<div class="main"> <div class="main-area">
<%= labelled_form_for resource, url: user_session_path do |f| %> <%= labeled_form_for resource, url: user_session_path do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, autocomplete: "email" %> <%= f.email_field :email, required: true, size: 30, autofocus: true,
<%= f.password_field :password, required: true, size: 30, minlength: @minimum_password_length, autocomplete: 'email' %>
autocomplete: "current-password" %> <%= f.password_field :password, required: true, size: 30,
minlength: @minimum_password_length, autocomplete: 'current-password' %>
<% if devise_mapping.rememberable? %> <% if devise_mapping.rememberable? %>
<%= f.check_box :remember_me, label: t(".remember_me") %> <%= f.check_box :remember_me %>
<% end %> <% end %>
<%= f.submit t(:sign_in) %> <%= f.submit t(:sign_in) %>
<% end %> <% end %>
<%= content_tag :p, t(:or), style: "text-align: center;" %> <%= content_tag :p, t(:or), style: 'text-align: center;' %>
<%= image_link_to t(:recover_password), 'lock-reset', new_user_password_path, <%= image_link_to t(:recover_password), 'lock-reset', new_user_password_path,
class: 'centered' %> class: 'centered' %>
</div> </div>

View File

@@ -1,19 +1,18 @@
<% content_for :navigation, flush: true do %> <% content_for :navigation, flush: true do %>
<div class="left"> <%= link_to svg_tag('pictograms/arrow-left-bold-outline', t(:back)), users_path,
<%= image_link_to t(:back), "arrow-left-bold-outline", users_path %> class: 'tab' %>
</div>
<% end %> <% end %>
<%= labelled_form_for @user do |f| %> <%= labeled_form_for @user, html: {class: 'main-area'} do |f| %>
<%= f.email_field :email, readonly: true %> <%= f.email_field :email, readonly: true %>
<% if f.object.pending_reconfirmation? %> <% if @user.pending_reconfirmation? %>
<%= f.email_field :unconfirmed_email, readonly: true, <%= f.email_field :unconfirmed_email, readonly: true,
hint: t("users.registrations.edit.unconfirmed_email_hint", confirmation_sent_at: l(@user.confirmation_sent_at) %>
timestamp: f.object.confirmation_sent_at.to_fs(:db_without_sec)) %>
<% end %> <% end %>
<%# TODO: allow status change here? %>
<%= f.select :status, User.statuses, readonly: true %> <%= f.select :status, User.statuses, readonly: true %>
<%= f.text_field :created_at, readonly: true %> <%= f.text_field :created_at, readonly: true %>
<%= f.text_field :confirmed_at, readonly: true %> <%= f.text_field :confirmed_at, readonly: true, placeholder: t(:no) %>
<% end %> <% end %>

View File

@@ -1,3 +1,4 @@
require 'core_ext/array_delete_bang'
require 'core_ext/big_decimal_scientific_notation' require 'core_ext/big_decimal_scientific_notation'
ActiveSupport.on_load :action_dispatch_system_test_case do ActiveSupport.on_load :action_dispatch_system_test_case do

View File

@@ -1,3 +0,0 @@
# Format contains non-breaking space:
# 160.chr(Encoding::UTF_8)
Time::DATE_FORMATS[:db_without_sec] = "%Y-%m-%d %H:%M"

View File

@@ -19,7 +19,19 @@ ActiveSupport.on_load :turbo_streams_tag_builder do
action :hide, target, allow_inferred_rendering: false action :hide, target, allow_inferred_rendering: false
end end
def show(target)
action :show, target, allow_inferred_rendering: false
end
#def collapse(target)
# action :collapse, target, allow_inferred_rendering: false
#end
def close_form(target) def close_form(target)
action :close_form, target, allow_inferred_rendering: false action :close_form, target, allow_inferred_rendering: false
end end
def unselect(target)
action :unselect, target, allow_inferred_rendering: false
end
end end

View File

@@ -1,22 +1,28 @@
en: en:
time:
formats:
# Format contains non-breaking space: 160.chr(Encoding::UTF_8)
default: "%Y-%m-%d %H:%M %Z"
without_tz: "%Y-%m-%d %H:%M"
errors: errors:
messages: messages:
precision_exceeded: must not exceed %{value} significant digits precision_exceeded: must not exceed %{value} significant digits
scale_exceeded: must not exceed %{value} decimal digits scale_exceeded: must not exceed %{value} decimal digits
activerecord: activerecord:
attributes: attributes:
unit: quantity:
symbol: Symbol description: Description
name: Name name: Name
multiplier: Multiplier unit:
base: Base unit base: Base unit
description: Description
multiplier: Multiplier
symbol: Symbol
user: user:
email: e-mail confirmed_at: Confirmed
status: status created_at: Registered
password: password email: E-mail
created_at: registered status: Status
confirmed_at: confirmed
unconfirmed_email: Awaiting confirmation for
errors: errors:
models: models:
unit: unit:
@@ -53,9 +59,22 @@ en:
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.
helpers: helpers:
label:
user:
password_confirmation: 'Retype new password:'
password_length_hint_html:
count: '%{minimum_password_length}'
zero:
one: <br><em>(%{count} character minimum)</em>
other: <br><em>(%{count} characters minimum)</em>
remember_me: 'Remember me:'
unconfirmed_email_html: >
Awaiting confirmation for:<br><em>(since %{confirmation_sent_at})</em>
submit: submit:
create: Create create: Create
update: Update update: Update
user:
update: Update profile
layouts: layouts:
application: application:
issue_tracker: Report issue issue_tracker: Report issue
@@ -64,10 +83,14 @@ en:
source_code: Get code source_code: Get code
measurements: measurements:
navigation: Measurements navigation: Measurements
no_items: There are no measurements taken. You can Add some now.
form:
select_quantity: select the measured quantities...
index: index:
new_quantity: Selected new_measurement: Add measurement
new_children: Children readouts:
new_subtree: Subtree form:
select_unit: ...
quantities: quantities:
navigation: Quantities navigation: Quantities
no_items: There are no configured quantities. You can Add some or Import from defaults. no_items: There are no configured quantities. You can Add some or Import from defaults.
@@ -125,33 +148,27 @@ en:
disguise: View as disguise: View as
passwords: passwords:
edit: edit:
new_password: New password password_html: 'New password:%{password_length_hint_html}'
password_confirmation: Retype new password
update_password: Update password update_password: Update password
registrations: registrations:
new: new:
password_confirmation: Retype password password_html: 'Password:%{password_length_hint_html}'
password_confirmation: 'Retype password:'
edit: edit:
confirm_delete: Are you sure you want to delete profile? confirm_delete: Are you sure you want to delete profile?
All data will be irretrievably lost. All data will be irretrievably lost.
delete: Delete profile delete: Delete profile
unconfirmed_email_hint: (since %{timestamp}) password_html: >
new_password: New password New password:
password_confirmation: Retype new password <br><em>leave blank to keep unchanged</em>
blank_password_hint_html: leave blank to keep unchanged<br>%{subhint} %{password_length_hint_html}
update: Update profile
sessions:
new:
remember_me: Remember me
minimum_password_length:
zero:
one: (%{count} character minimum)
other: (%{count} characters minimum)
actions: Actions actions: Actions
add: Add add: Add
apply: Apply
back: Back back: Back
cancel: Cancel cancel: Cancel
delete: Delete delete: Delete
:no: 'no'
or: or or: or
register: Register register: Register
sign_in: Sign in sign_in: Sign in

View File

@@ -1,8 +1,8 @@
Rails.application.routes.draw do Rails.application.routes.draw do
resources :measurements, path_names: {new: '/new(/:scope)'}, resources :measurements
constraints: {scope: /children|subtree/}, defaults: {scope: nil} do
get 'discard/:id', on: :new, action: :discard, as: :discard resources :readouts, only: [:new] do
collection {get 'new/:id/discard', action: :discard, as: :discard}
end end
resources :quantities, except: [:show], path_names: {new: '(/:id)/new'} do resources :quantities, except: [:show], path_names: {new: '(/:id)/new'} do

View File

@@ -5,7 +5,7 @@ class CreateUnits < ActiveRecord::Migration[7.0]
t.string :symbol, null: false, limit: 15 t.string :symbol, null: false, limit: 15
t.text :description t.text :description
t.decimal :multiplier, null: false, precision: 30, scale: 15, default: 1.0 t.decimal :multiplier, null: false, precision: 30, scale: 15, default: 1.0
t.references :base, foreign_key: {to_table: :units} t.references :base, foreign_key: {to_table: :units, on_delete: :cascade}
t.timestamps null: false t.timestamps null: false
end end

View File

@@ -4,7 +4,7 @@ class CreateQuantities < ActiveRecord::Migration[7.2]
t.references :user, foreign_key: true t.references :user, foreign_key: true
t.string :name, null: false, limit: 31 t.string :name, null: false, limit: 31
t.text :description t.text :description
t.references :parent, foreign_key: {to_table: :quantities} t.references :parent, foreign_key: {to_table: :quantities, on_delete: :cascade}
t.timestamps null: false t.timestamps null: false

View File

@@ -69,11 +69,11 @@ ActiveRecord::Schema[7.2].define(version: 2025_01_21_230456) do
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end end
add_foreign_key "quantities", "quantities", column: "parent_id" add_foreign_key "quantities", "quantities", column: "parent_id", on_delete: :cascade
add_foreign_key "quantities", "users" add_foreign_key "quantities", "users"
add_foreign_key "readouts", "quantities" add_foreign_key "readouts", "quantities"
add_foreign_key "readouts", "units" add_foreign_key "readouts", "units"
add_foreign_key "readouts", "users" add_foreign_key "readouts", "users"
add_foreign_key "units", "units", column: "base_id" add_foreign_key "units", "units", column: "base_id", on_delete: :cascade
add_foreign_key "units", "users" add_foreign_key "units", "users"
end end

View File

@@ -1,5 +1,5 @@
Unit.transaction do Unit.transaction do
Unit.defaults.order(Unit.arel_table[:base_id].eq(nil)).delete_all Unit.defaults.delete_all
units = {} units = {}
<% Unit.defaults.ordered.each do |unit| %> <% Unit.defaults.ordered.each do |unit| %>
<%= "\n" if unit.base.nil? %> <%= "\n" if unit.base.nil? %>

View File

@@ -1,5 +1,5 @@
Unit.transaction do Unit.transaction do
Unit.defaults.order(Unit.arel_table[:base_id].eq(nil)).delete_all Unit.defaults.delete_all
units = {} units = {}

View File

@@ -0,0 +1,10 @@
module CoreExt
module ArrayDeleteBang
def delete!(obj)
self.delete(obj)
self
end
end
end
Array.prepend CoreExt::ArrayDeleteBang

View File

@@ -1,7 +1,6 @@
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
@@ -19,8 +18,8 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
def sign_in(user: users.select(&:confirmed?).sample, password: randomize_user_password!(user)) def sign_in(user: users.select(&:confirmed?).sample, password: randomize_user_password!(user))
visit new_user_session_url visit new_user_session_url
fill_in User.human_attribute_name(:email).capitalize, with: user.email fill_in User.human_attribute_name(:email), with: user.email
fill_in User.human_attribute_name(:password).capitalize, with: password fill_in User.human_attribute_name(:password), with: password
click_on t(:sign_in) click_on t(:sign_in)
user user
end end
@@ -30,6 +29,13 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
evaluate_script("arguments[0].insertAdjacentHTML('beforeend', '#{button.html_safe}');", after) evaluate_script("arguments[0].insertAdjacentHTML('beforeend', '#{button.html_safe}');", after)
end end
# Allow skipping interpolations when translating for testing purposes
INTERPOLATION_PATTERNS = Regexp.union(I18n.config.interpolation_patterns)
def translate(key, **options)
options.empty? ? super.split(INTERPOLATION_PATTERNS, 2).first : super
end
alias :t :translate
#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

View File

@@ -6,14 +6,14 @@ require "application_system_test_case"
# * user with no units # * user with no units
class UnitsTest < ApplicationSystemTestCase class UnitsTest < ApplicationSystemTestCase
LINK_LABELS = { LINK_LABELS = {}
new_unit: t('units.index.new_unit'),
new_subunit: t('units.unit.new_subunit'),
edit: nil
}
setup do setup do
@user = sign_in @user = sign_in
LINK_LABELS.clear
LINK_LABELS[:new_unit] = t('units.index.new_unit')
LINK_LABELS[:new_subunit] = t('units.unit.new_subunit')
LINK_LABELS[:edit] = Regexp.union(@user.units.map(&:symbol)) LINK_LABELS[:edit] = Regexp.union(@user.units.map(&:symbol))
visit units_path visit units_path
@@ -26,7 +26,7 @@ class UnitsTest < ApplicationSystemTestCase
end end
# Cannot #destroy_all due to {dependent: :restrict*} on Unit.subunits association # Cannot #destroy_all due to {dependent: :restrict*} on Unit.subunits association
@user.units.order(Unit.arel_table[:base_id].eq(nil)).delete_all @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

View File

@@ -6,15 +6,21 @@ class UsersTest < ApplicationSystemTestCase
end end
test "sign in" do test "sign in" do
visit new_user_session_path
assert find_link(href: new_user_session_path)[:disabled]
sign_in sign_in
assert_no_current_path new_user_session_path assert_no_current_path new_user_session_path
assert_text t("devise.sessions.signed_in") assert_text t('devise.sessions.signed_in')
end end
test "sign in fails with invalid password" do test 'sign in fails with invalid password' do
sign_in password: random_password sign_in password: random_password
assert_current_path new_user_session_path assert_current_path new_user_session_path
assert_text t("devise.failure.invalid", authentication_keys: User.human_attribute_name(:email)) assert_text t('devise.failure.not_found_in_database',
authentication_keys: User.human_attribute_name(:email))
assert find_link(href: new_user_session_path)[:disabled]
assert_not_empty find_field(User.human_attribute_name(:email)).value
end end
test "sign out" do test "sign out" do
@@ -29,7 +35,7 @@ class UsersTest < ApplicationSystemTestCase
visit new_user_session_url visit new_user_session_url
click_on t(:recover_password) click_on t(:recover_password)
fill_in User.human_attribute_name(:email).capitalize, fill_in User.human_attribute_name(:email),
with: users.select(&:confirmed?).sample.email with: users.select(&:confirmed?).sample.email
assert_emails 1 do assert_emails 1 do
click_on t(:recover_password) click_on t(:recover_password)
@@ -42,8 +48,8 @@ class UsersTest < ApplicationSystemTestCase
visit Capybara.string(mail.body.to_s).find_link("Change my password")[:href] visit Capybara.string(mail.body.to_s).find_link("Change my password")[:href]
end end
new_password = random_password new_password = random_password
fill_in t("users.passwords.edit.new_password"), with: new_password fill_in t("users.passwords.edit.password_html"), with: new_password
fill_in t("users.passwords.edit.password_confirmation"), with: new_password fill_in t("helpers.label.user.password_confirmation"), with: new_password
assert_emails 1 do assert_emails 1 do
click_on t("users.passwords.edit.update_password") click_on t("users.passwords.edit.update_password")
# Wait until redirected to make sure async request has been processed # Wait until redirected to make sure async request has been processed
@@ -56,9 +62,9 @@ class UsersTest < ApplicationSystemTestCase
visit new_user_session_url visit new_user_session_url
click_on t(:register) click_on t(:register)
fill_in User.human_attribute_name(:email).capitalize, with: random_email fill_in User.human_attribute_name(:email), with: random_email
password = random_password password = random_password
fill_in User.human_attribute_name(:password).capitalize, with: password fill_in User.human_attribute_name(:password), with: password
fill_in t("users.registrations.new.password_confirmation"), with: password fill_in t("users.registrations.new.password_confirmation"), with: password
assert_difference ->{User.count}, 1 do assert_difference ->{User.count}, 1 do
assert_emails 1 do assert_emails 1 do
@@ -82,7 +88,7 @@ class UsersTest < ApplicationSystemTestCase
click_on t(:register) click_on t(:register)
click_on t(:resend_confirmation) click_on t(:resend_confirmation)
fill_in User.human_attribute_name(:email).capitalize, fill_in User.human_attribute_name(:email),
with: users.reject(&:confirmed?).sample.email with: users.reject(&:confirmed?).sample.email
assert_emails 1 do assert_emails 1 do
click_on t(:resend_confirmation) click_on t(:resend_confirmation)
@@ -151,9 +157,10 @@ class UsersTest < ApplicationSystemTestCase
end end
assert_difference ->{ User.count }, -1 do assert_difference ->{ User.count }, -1 do
accept_confirm { click_on t("users.registrations.edit.delete") } accept_confirm { click_on t("users.registrations.edit.delete") }
end
assert_current_path new_user_session_path assert_current_path new_user_session_path
end end
assert_text t("devise.registrations.destroyed")
end
test "index forbidden for non admin" do test "index forbidden for non admin" do
sign_in user: users.reject(&:admin?).select(&:confirmed?).sample sign_in user: users.reject(&:admin?).select(&:confirmed?).sample