3 Commits

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

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 148 B

View File

@@ -15,13 +15,6 @@
*= 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;
@@ -55,24 +48,37 @@
} }
/* Color coding of input controls' background: /* TODO: collapse gaps around empty rows (`topside`) once possible
* blue - target for interaction with pointer * https://github.com/w3c/csswg-drafts/issues/5813 */
* gray - target for interaction with keyboard body {
* red - destructive, non-undoable action display: grid;
*/ gap: 0.8em;
grid-template-areas:
"header header header"
"nav nav nav"
"leftside topside rightside"
"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] {
@@ -95,12 +101,11 @@ input[type=submit]:not([hidden]),
button, button,
input[type=submit] { input[type=submit] {
font-size: 0.8rem; font-size: 0.8rem;
padding: 0.6em 0.5em; padding: 0.4em;
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;
} }
@@ -108,23 +113,68 @@ 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;
} }
input[type=checkbox], fieldset,
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.1em; height: 1.1rem;
width: 1.1em; margin: 0;
width: 1.1rem;
} }
input[type=checkbox]:checked { input[type=checkbox]:checked {
appearance: checkbox; appearance: checkbox;
@@ -142,96 +192,42 @@ 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-inline: 0; padding-left: 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;
@@ -244,7 +240,7 @@ header {
flex: 1; flex: 1;
font-size: 1rem; font-size: 1rem;
justify-content: center; justify-content: center;
padding-block: 0.4em; padding-block: 0.3em;
} }
.navigation > .tab:hover, .navigation > .tab:hover,
.navigation > .tab:focus-visible { .navigation > .tab:focus-visible {
@@ -256,19 +252,21 @@ header {
fill: var(--color-blue); fill: var(--color-blue);
} }
.topside-area {
.topside {
grid-area: topside; grid-area: topside;
} }
.leftside-area { .leftside {
grid-area: leftside; grid-area: leftside;
} }
.main-area { .main {
grid-area: main; grid-area: main;
} }
.rightside-area { .rightside {
grid-area: rightside; grid-area: rightside;
} }
.buttongrid { .buttongrid {
display: grid; display: grid;
gap: 0.4em; gap: 0.4em;
@@ -276,7 +274,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-area { .tools {
grid-area: tools; grid-area: tools;
} }
@@ -340,43 +338,47 @@ header {
opacity: 1; opacity: 1;
} }
/* TODO: Hover over invalid should work like in measurements (thin vs thick border) */
.labeled-form { /* TODO: Update styling, including rem removal. */
align-items: center; form table {
display: grid; border-spacing: 0.8rem;
gap: 0.9em 1.1em;
grid-template-columns: 1fr minmax(max-content, 0.5fr) 1fr;
} }
.labeled-form label { form tr td:first-child {
color: var(--color-gray); color: var(--color-gray);
font-size: 0.9rem; font-size: 0.9rem;
grid-column: 1; padding-right: 0.25rem;
text-align: right; text-align: right;
white-space: nowrap;
} }
.labeled-form label.required { form label.required {
font-weight: bold; font-weight: bold;
} }
/* Don't style `label.error + input` if case already covered by input:invalid */ form label.error,
.labeled-form label.error { form td.error::after {
color: var(--color-red); color: var(--color-red);
} }
.labeled-form em { form td.error {
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;
} }
.labeled-form input { form input[type=submit] {
grid-column: 2; float: none;
}
.labeled-form input[type=submit] {
font-size: 1rem; font-size: 1rem;
margin: 1.5em auto 0 auto; margin: 1.5rem auto 0 auto;
padding: 0.75em; padding: 0.75rem;
} }
/* 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);
@@ -384,9 +386,6 @@ 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;
} }
@@ -463,7 +462,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 table styling: simplify selectors, deduplicate, remove non-font rem. */ /* TODO: Update styling, including rem removal. */
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 {
@@ -486,13 +485,15 @@ 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 svg { table.items td.hint {
height: 1rem; color: var(--color-table-gray);
vertical-align: middle; font-style: italic;
width: 1rem; font-size: 0.8rem;
padding: 1em;
} }
table.items svg:last-child { table.items svg {
height: 1.2rem; height: 1.2rem;
vertical-align: middle;
width: 1.2rem; width: 1.2rem;
} }
table.items td.svg { table.items td.svg {
@@ -505,8 +506,7 @@ 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;
height: 100%; padding: 0.3em;
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,
@@ -532,6 +532,7 @@ 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,31 +550,21 @@ 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 { .hflex.centered {
justify-content: center; justify-content: center;
} }
.hint { .vexpand {
color: var(--color-table-gray); width: 100%;
font-style: italic;
font-size: 0.9rem;
text-align: center;
} }
.vflex { .vflex {
display: flex; display: flex;
@@ -581,81 +572,12 @@ form table.items td:first-child {
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

@@ -9,7 +9,6 @@ class ApplicationController < ActionController::Base
helper_method :current_user_disguised? helper_method :current_user_disguised?
helper_method :current_tab helper_method :current_tab
before_action :redirect_to_setup_if_needed
before_action :authenticate_user! before_action :authenticate_user!
class AccessForbidden < StandardError; end class AccessForbidden < StandardError; end
@@ -44,16 +43,6 @@ class ApplicationController < ActionController::Base
private private
# Redirect to the web setup wizard when the application has not yet been
# initialised (i.e. no admin account exists in the database).
def redirect_to_setup_if_needed
return if User.exists?(status: :admin)
redirect_to new_setup_path
rescue ActiveRecord::StatementInvalid
# Tables may not exist yet (migrations not run). Fall through and let the
# normal request handling surface a meaningful error.
end
def render_no_content(record) def render_no_content(record)
helpers.render_errors(record) helpers.render_errors(record)
render html: nil, layout: true render html: nil, layout: true

View File

@@ -1,16 +1,23 @@
class ReadoutsController < ApplicationController class ReadoutsController < ApplicationController
before_action :find_quantities, only: [:new] before_action :find_quantity, only: [:new, :discard]
before_action :find_quantity, only: [:discard]
before_action :find_prev_quantities, only: [:new, :discard] before_action :find_prev_quantities, only: [:new, :discard]
def new def new
@quantities -= @prev_quantities new_quantities =
# TODO: raise ParameterInvalid if new_quantities.empty? case params[:button]
@readouts = current_user.readouts.build(@quantities.map { |q| {quantity: q} }) when 'children'
@quantity.subquantities
when 'subtree'
@quantity.progenies
else
[@quantity]
end
new_quantities -= @prev_quantities
@readouts = current_user.readouts.build(new_quantities.map { |q| {quantity: q} })
@user_units = current_user.units.ordered @user_units = current_user.units.ordered
quantities = @prev_quantities + @quantities quantities = @prev_quantities + new_quantities
@superquantity = current_user.quantities @superquantity = current_user.quantities
.common_ancestors(quantities.map(&:parent_id)).first .common_ancestors(quantities.map(&:parent_id)).first
end end
@@ -24,10 +31,6 @@ class ReadoutsController < ApplicationController
private private
def find_quantities
@quantities = current_user.quantities.find(params[:quantity])
end
def find_quantity def find_quantity
@quantity = current_user.quantities.find_by!(id: params[:id]) @quantity = current_user.quantities.find_by!(id: params[:id])
end end

View File

@@ -1,26 +1,8 @@
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
if current_user.sole_admin?
redirect_back fallback_location: edit_user_registration_path,
alert: t(".sole_admin")
return
end
super
end
protected protected
def build_resource(hash = {})
super
# Skip the email confirmation step when the admin has enabled this option
# via the web setup wizard (stored as the "skip_email_confirmation" Setting).
# The account becomes active immediately so the user can sign in right after
# registering.
resource.skip_confirmation! if Setting.get("skip_email_confirmation") == "true"
end
def update_resource(resource, params) def update_resource(resource, params)
# Based on update_with_password() # Based on update_with_password()
if params[:password].blank? if params[:password].blank?

View File

@@ -1,59 +0,0 @@
# Handles the one-time web-based installation wizard.
#
# The wizard is only accessible when no admin account exists yet. Once an
# admin has been created the controller redirects every request to the root
# path, so it can never be used to overwrite an existing installation.
class SetupController < ActionController::Base
# Use the full application layout (header, flash, etc.) so the page looks
# consistent with the rest of the site.
layout "application"
before_action :redirect_if_installed
def new
end
def create
email = params[:admin_email].to_s.strip
password = params[:admin_password].to_s
confirm = params[:admin_password_confirmation].to_s
errors = []
errors << t(".email_blank") if email.blank?
errors << t(".password_blank") if password.blank?
errors << t(".password_mismatch") if password != confirm
if errors.any?
flash.now[:alert] = errors.join(" ")
return render :new, status: :unprocessable_entity
end
user = User.new(email: email, password: password, status: :admin)
user.skip_confirmation!
unless user.save
flash.now[:alert] = user.errors.full_messages.join(" ")
return render :new, status: :unprocessable_entity
end
# Persist runtime settings chosen during setup.
Setting.set("skip_email_confirmation",
params[:skip_email_confirmation] == "1")
# Optionally seed the built-in default units.
if params[:seed_units] == "1"
load Rails.root.join("db/seeds/units.rb")
end
redirect_to new_user_session_path, notice: t(".success")
end
private
def redirect_if_installed
redirect_to root_path if User.exists?(status: :admin)
rescue ActiveRecord::StatementInvalid
# Tables are not yet migrated — stay on the setup page so the user sees a
# meaningful error rather than a crash.
end
end

View File

@@ -1,84 +1,59 @@
module ApplicationHelper module ApplicationHelper
class LabeledFormBuilder < ActionView::Helpers::FormBuilder # TODO: replace legacy content_tag with tag.tagname
(field_helpers - [:label, :hidden_field]).each do |selector| class LabelledFormBuilder < ActionView::Helpers::FormBuilder
(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 = {})
labeled_field_for(method, options) { super } labelled_row_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 = {})
labeled_field_for(method, options) { super } labelled_row_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 labeled_field_for(method, options) def labelled_row_for(method, options)
field = if options.delete(:readonly) then @template.content_tag :tr do
value = object.public_send(method) @template.content_tag(:td, label_for(method, options), class: "unwrappable") +
value = @template.l(value) if value.respond_to?(:strftime) @template.content_tag(:td, options.delete(:readonly) ? @object.public_send(method) : yield,
value ||= options[:placeholder] @object&.errors[method].present? ?
else {class: "error", data: {content: @object&.errors.delete(method).join(" and ")}} :
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)
handler = {missing_interpolation_argument_handler: method(:interpolation_missing)} label + (@template.tag(:br) + @template.content_tag(:em, hint) if hint)
# 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
def interpolation_missing(key, values, string)
@template.instance_values[key.to_s] || deep_translate(key, **values)
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
end end
def labeled_form_for(record, options = {}, &block) def labelled_form_for(record, options = {}, &block)
extra_options = {builder: LabeledFormBuilder, options = options.deep_merge(builder: LabelledFormBuilder, data: {turbo: false})
data: {turbo: false}, form_for(record, **options) { |f| f.form_for(&block) }
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
@@ -100,11 +75,12 @@ module ApplicationHelper
end end
def number_field(method, options = {}) def number_field(method, options = {})
attr_type = object.type_for_attribute(method) value = object.public_send(method)
if attr_type.type == :decimal if value.is_a?(BigDecimal)
options[:value] = object.public_send(method)&.to_scientific options[:value] = value.to_scientific
options[:step] ||= BigDecimal(10).power(-attr_type.scale) type = object.class.type_for_attribute(method)
options[:max] ||= BigDecimal(10).power(attr_type.precision - attr_type.scale) - options[:step] ||= BigDecimal(10).power(-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]
@@ -136,7 +112,6 @@ 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
@@ -148,7 +123,7 @@ module ApplicationHelper
end end
def svg_tag(source, label = nil, options = {}) def svg_tag(source, label = nil, options = {})
svg_tag = tag.svg(options) do svg_tag = content_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)
@@ -196,23 +171,17 @@ 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)
# NOTE: Starting from Rails 8.1.0, below condition can be replaced with: html_options = html_options.deep_merge DISABLED_ATTRIBUTES if current_page?(options)
# 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)
# Conversion of flash to Array only required because of Devise flash[:alert] ||= []
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,
@@ -235,6 +204,8 @@ 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',
@@ -245,10 +216,9 @@ module ApplicationHelper
html_options[:onclick] = "return confirm('\#{html_options[:onclick][:confirm]}');" html_options[:onclick] = "return confirm('\#{html_options[:onclick][:confirm]}');"
end end
link_is_local = html_options[:onclick] || html_options.dig(:data, :turbo_stream) if type == :link && !(html_options[:onclick] || html_options.dig(:data, :turbo_stream))
name = name.to_s name += '...'
name += '...' if type == :link && !link_is_local end
name = svg_tag("pictograms/#{image}", name) if image
[name, html_options] [name, html_options]
end end

View File

@@ -1,11 +1,2 @@
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,70 +9,31 @@ 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")
// Assume 'tabindex' is not used explicitly, so removing it is safe // '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 */
@@ -93,63 +54,28 @@ 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 */
var lastEnterTime function processKey(event) {
function dragStart(event) { if (event.key == "Escape") {
lastEnterTime = event.timeStamp event.currentTarget.querySelector("a[name=cancel]").click();
var row = event.currentTarget }
row.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden")
})
event.dataTransfer.setData("text/plain", row.getAttribute("data-drag-path"))
var rowRectangle = row.getBoundingClientRect()
event.dataTransfer.setDragImage(row, event.x - rowRectangle.left, event.y - rowRectangle.top)
event.dataTransfer.dropEffect = "move"
} }
window.dragStart = dragStart window.processKey = processKey;
var lastEnterTime;
function dragStart(event) {
lastEnterTime = event.timeStamp;
var row = event.currentTarget;
row.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden");
});
event.dataTransfer.setData("text/plain", row.getAttribute("data-drag-path"));
var rowRectangle = row.getBoundingClientRect();
event.dataTransfer.setDragImage(row, event.x - rowRectangle.left, event.y - rowRectangle.top);
event.dataTransfer.dropEffect = "move";
}
window.dragStart = dragStart;
/* /*
* Drag tracking assumptions (based on FF 122.0 experience): * Drag tracking assumptions (based on FF 122.0 experience):
@@ -161,44 +87,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,
@@ -212,4 +138,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

@@ -15,8 +15,8 @@ class Quantity < ApplicationRecord
errors.add(:parent, :descendant_reference) if ancestor_of?(parent) errors.add(:parent, :descendant_reference) if ancestor_of?(parent)
end end
validates :name, presence: true, uniqueness: {scope: [:user_id, :parent_id]}, validates :name, presence: true, uniqueness: {scope: [:user_id, :parent_id]},
length: {maximum: type_for_attribute(:name).limit || Float::INFINITY} length: {maximum: type_for_attribute(:name).limit}
validates :description, length: {maximum: type_for_attribute(:description).limit || Float::INFINITY} validates :description, length: {maximum: type_for_attribute(:description).limit}
# Update :depths of progenies after parent change # Update :depths of progenies after parent change
before_save if: :parent_changed? do before_save if: :parent_changed? do

View File

@@ -1,20 +0,0 @@
# Key-value store for runtime application settings that are configured through
# the web setup wizard (or updated by an administrator) rather than hard-coded
# in application.rb.
#
# Known keys:
# skip_email_confirmation "true"/"false", mirrors the homonymous option
# that was previously in application.rb.
class Setting < ApplicationRecord
validates :key, presence: true, uniqueness: true
# Return the string value stored for +key+, or +default+ when absent.
def self.get(key, default: nil)
find_by(key: key)&.value || default
end
# Persist +value+ for +key+, creating the record if it does not yet exist.
def self.set(key, value)
find_or_initialize_by(key: key).update!(value: value.to_s)
end
end

View File

@@ -3,8 +3,7 @@ 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, has_many :subunits, class_name: "Unit", inverse_of: :base, dependent: :restrict_with_error
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
@@ -12,8 +11,8 @@ class Unit < ApplicationRecord
errors.add(:base, :multilevel_nesting) if base.base_id? errors.add(:base, :multilevel_nesting) if base.base_id?
end end
validates :symbol, presence: true, uniqueness: {scope: :user_id}, validates :symbol, presence: true, uniqueness: {scope: :user_id},
length: {maximum: type_for_attribute(:symbol).limit || Float::INFINITY} length: {maximum: type_for_attribute(:symbol).limit}
validates :description, length: {maximum: type_for_attribute(:description).limit || Float::INFINITY} validates :description, length: {maximum: type_for_attribute(:description).limit}
validates :multiplier, numericality: {equal_to: 1}, unless: :base validates :multiplier, numericality: {equal_to: 1}, unless: :base
validates :multiplier, numericality: {greater_than: 0, precision: true, scale: true}, if: :base validates :multiplier, numericality: {greater_than: 0, precision: true, scale: true}, if: :base

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: :delete_all has_many :readouts, dependent: :destroy
accepts_nested_attributes_for :readouts accepts_nested_attributes_for :readouts
has_many :quantities, dependent: :delete_all has_many :quantities, dependent: :destroy
has_many :units, dependent: :delete_all has_many :units, dependent: :destroy
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}
@@ -29,11 +29,4 @@ class User < ApplicationRecord
def at_least(status) def at_least(status)
User.statuses[self.status] >= User.statuses[status] User.statuses[self.status] >= User.statuses[status]
end end
# Returns true when this user is the only admin account in the system.
# Used to block actions that would leave the application without an admin
# (account deletion, status demotion).
def sole_admin?
admin? && !User.admin.where.not(id: id).exists?
end
end end

View File

@@ -1,17 +1,16 @@
<div class="rightside-area buttongrid"> <div class="rightside 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, <%= image_link_to t('.back'), 'arrow-left-bold-outline', units_path, class: 'tools' %>
class: 'tools-area' %>
</div> </div>
<table class="main-area items"> <table class="main items">
<thead> <thead>
<tr> <tr>
<th><%= Unit.human_attribute_name(:symbol) %></th> <th><%= User.human_attribute_name(:symbol).capitalize %></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>
<%# Allows overwriting/clearing navigation menu for some views %> <%# Allow 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,36 +1,24 @@
<%= tabular_form_with model: Measurement.new, id: :measurement_form, <%= tabular_form_with model: Measurement.new do |form| %>
class: 'topside-area vflex', html: {onkeydown: 'formProcessKey(event)'} do |form| %> <fieldset>
<table class="items centered"> <table class="items centered">
<tbody id="readouts"></tbody> <tbody id="readouts">
</table> <tr id="readouts_form">
<td colspan="4">
<div class="hflex"> <%= collection_select :quantity, :id, @quantities, :id, :to_s_with_depth,
<%# TODO: right-click selection %> {prompt: t('.select_quantity'), disabled: '', selected: ''},
<details id="quantity_select" class="hexpand" open {name: :id, class: 'quantity vexpand',
onkeydown="detailsProcessKey(event)"> onchange: "this.form.requestSubmit(new_readout_submit);"} %>
<summary autofocus> <%= form.submit id: :new_readout_submit, name: nil, value: nil,
<!-- TODO: Set content with CSS when span empty to avoid duplication --> formaction: new_readout_path, formmethod: :get, formnovalidate: true,
<span data-prompt="<%= t('.select_quantity') %>"> hidden: true, data: {turbo_stream: true} %>
<%= t('.select_quantity') %> </td>
</span> </tr>
<%= image_button_tag t(:apply), "update", name: nil, disabled: true, </tbody>
formaction: new_readout_path, formmethod: :get, formnovalidate: true, </table>
data: {turbo_stream: true} %> </fieldset>
</summary> <div class="hflex centered">
<ul><%= quantities_check_boxes %></ul> <%= form.button -%>
</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, <%= image_link_to t(:cancel), "close-outline", measurements_path, name: :cancel,
class: 'dangerous', onclick: render_turbo_stream('form_close') %> class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</div> </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,4 +1,2 @@
<%= turbo_stream.update :measurement_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,5 +1,5 @@
<%# TODO: show hint when no quantities/units defined %> <%# TODO: show hint when no quantities/units defined %>
<div class="rightside-area buttongrid"> <div class="rightside buttongrid">
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<%= image_link_to t('.new_measurement'), 'plus-outline', new_measurement_path, <%= image_link_to t('.new_measurement'), 'plus-outline', new_measurement_path,
id: :new_measurement_link, onclick: 'this.blur();', id: :new_measurement_link, onclick: 'this.blur();',
@@ -7,7 +7,9 @@
<% end %> <% end %>
</div> </div>
<table class="main-area"> <%= tag.div class: 'topside', id: :measurement_form %>
<table class="main">
<tbody id="measurements"> <tbody id="measurements">
<%= render(@measurements) || render_no_items %> <%= render(@measurements) || render_no_items %>
</tbody> </tbody>

View File

@@ -1,5 +1,4 @@
<%= turbo_stream.disable :new_measurement_link -%> <%= turbo_stream.disable :new_measurement_link -%>
<%= turbo_stream.hide :no_items -%> <%= turbo_stream.update :measurement_form do %>
<%= turbo_stream.append_all 'body' 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: "formProcessKey(event)", <%- tag.tr id: row, class: "form", onkeydown: "processKey(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,20 +1,20 @@
<div class="rightside-area buttongrid"> <div class="rightside 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();', id: dom_id(Quantity, :new, :link), onclick: 'this.blur();',
data: {turbo_stream: true} %> 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-area' %> class: 'tools' %>
</div> </div>
<%= tag.div class: 'main-area', id: :quantity_form %> <%= tag.div class: 'main', id: :quantity_form %>
<table class="main-area items"> <table class="main items">
<thead> <thead>
<tr> <tr>
<th><%= Quantity.human_attribute_name(:name) %></th> <th><%= Quantity.human_attribute_name(:name).capitalize %></th>
<th><%= Quantity.human_attribute_name(:description) %></th> <th><%= Quantity.human_attribute_name(:description).capitalize %></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,25 +1,22 @@
<%# TODO: add readout reordering by dragging %> <%# TODO: add readout reordering by dragging %>
<%= tabular_fields_for 'readouts[]', readout do |form| %> <%= tabular_fields_for 'readouts[]', readout do |form| %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout) do %> <%- tag.tr id: dom_id(readout.quantity, :new, :readout),
<td class="actions"> onkeydown: 'processKey(event)' do %>
<%# 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> <td>
<%= readout.quantity.relative_pathname(@superquantity) %> <%= readout.quantity.relative_pathname(@superquantity) %>
</td> </td>
<td> <td>
<%= form.number_field :value, required: true, <%= form.number_field :value, required: true, autofocus: true, size: 10 %>
size: readout.type_for_attribute(:value).precision / 2,
autofocus: readout_counter == 0 %>
</td> </td>
<td> <td>
<%= form.hidden_field :quantity_id %> <%= form.hidden_field :quantity_id %>
<%= form.collection_select :unit_id, @user_units, :id, <%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) }, ->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) } %>
{prompt: t('.select_unit'), disabled: '', selected: ''}, required: true %> </td>
<td class="actions">
<%= image_button_tag '', 'delete-outline', class: 'dangerous', name: :discard,
formaction: discard_readouts_path(readout.quantity),
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>
</td> </td>
<% end %> <% end %>
<% end %> <% end %>

View File

@@ -1,4 +1,4 @@
<%= turbo_stream.disable :create_measurement_button if @prev_quantities.one? %>
<%= turbo_stream.remove dom_id(@quantity, :new, :readout) %> <%= turbo_stream.remove dom_id(@quantity, :new, :readout) %>
<%= turbo_stream.disable_all 'button[name="discard"]' if @prev_quantities.one? %>
<%= turbo_stream.enable_all "select.quantity option[value='#{@quantity.id}']" %>
<%= render partial: 'form_repath' %> <%= render partial: 'form_repath' %>
<%= turbo_stream.unselect dom_id(@quantity) %>

View File

@@ -1,8 +1,9 @@
<% @readouts.each do |r| %>
<%= turbo_stream.disable dom_id(r.quantity) %>
<% end %>
<%= render partial: 'form_repath' %> <%= render partial: 'form_repath' %>
<%= turbo_stream.append :readouts do %> <%# is .one? proper condition? can @readouts be empty? %>
<%= turbo_stream.enable_all 'button[name="discard"]' if @prev_quantities.one? %>
<% @readouts.each do |r| %>
<%= turbo_stream.disable_all "select.quantity option[value='#{r.quantity_id}']" %>
<% end %>
<%= turbo_stream.before :readouts_form do %>
<%= render partial: 'form', collection: @readouts, as: :readout %> <%= render partial: 'form', collection: @readouts, as: :readout %>
<% end %> <% end %>
<%= turbo_stream.enable :create_measurement_button if @prev_quantities.empty? %>

View File

@@ -1,39 +0,0 @@
<%= form_with url: setup_path, method: :post, class: "labeled-form main-area" do %>
<h3 style="grid-column: 1 / -1; text-align: left; margin: 0;">
<%= t(".admin_account") %>
</h3>
<label for="admin_email"><%= t(".admin_email") %></label>
<%= email_field_tag :admin_email, params[:admin_email],
id: "admin_email", required: true, size: 30, autofocus: true,
autocomplete: "email" %>
<label for="admin_password"><%= t(".admin_password") %></label>
<%= password_field_tag :admin_password, nil,
id: "admin_password", required: true, size: 30,
autocomplete: "new-password" %>
<label for="admin_password_confirmation"><%= t(".admin_password_confirmation") %></label>
<%= password_field_tag :admin_password_confirmation, nil,
id: "admin_password_confirmation", required: true, size: 30,
autocomplete: "off" %>
<h3 style="grid-column: 1 / -1; text-align: left; margin: 0.5em 0 0 0;">
<%= t(".options") %>
</h3>
<label for="skip_email_confirmation" style="grid-column: 1 / 3; text-align: left;">
<%= check_box_tag :skip_email_confirmation, "1",
params[:skip_email_confirmation] == "1",
id: "skip_email_confirmation" %>
<%= t(".skip_email_confirmation") %>
</label>
<label for="seed_units" style="grid-column: 1 / 3; text-align: left;">
<%= check_box_tag :seed_units, "1", true, id: "seed_units" %>
<%= t(".seed_units") %>
</label>
<%= submit_tag t(".submit") %>
<% end %>

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: "formProcessKey(event)", <%- tag.tr id: row, class: "form", onkeydown: "processKey(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,20 +1,19 @@
<div class="rightside-area buttongrid"> <div class="rightside 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, <%= image_link_to t('.import_units'), 'download-outline', default_units_path, class: 'tools' %>
class: 'tools-area' %>
</div> </div>
<%= tag.div id: :unit_form %> <%= tag.div id: :unit_form %>
<table class="main-area items"> <table class="main items">
<thead> <thead>
<tr> <tr>
<th><%= Unit.human_attribute_name(:symbol) %></th> <th><%= User.human_attribute_name(:symbol).capitalize %></th>
<th><%= Unit.human_attribute_name(:description) %></th> <th><%= User.human_attribute_name(:description).capitalize %></th>
<th><%= Unit.human_attribute_name(:multiplier) %></th> <th><%= User.human_attribute_name(:multiplier).capitalize %></th>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<th><%= t :actions %></th> <th><%= t :actions %></th>
<th></th> <th></th>

View File

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

View File

@@ -1,10 +1,12 @@
<table class="main-area items" id="users"> <table class="main items" id="users">
<thead> <thead>
<tr> <tr>
<th><%= User.human_attribute_name(:email) %></th> <th><%= User.human_attribute_name(:email).capitalize %></th>
<th><%= User.human_attribute_name(:status) %></th> <th><%= User.human_attribute_name(:status).capitalize %></th>
<th><%= User.human_attribute_name(:confirmed_at) %></th> <th><%= User.human_attribute_name(:confirmed_at).capitalize %></th>
<th><%= User.human_attribute_name(:created_at) %>&nbsp;<sup>(UTC)</sup></th> <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>
@@ -17,18 +19,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><%= l user.created_at, format: :without_tz %></td> <td><%= user.created_at.to_fs(:db_without_sec) %></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,12 +1,13 @@
<%= labeled_form_for resource, url: user_password_path, <div class="main">
html: {method: :put, class: 'main-area'} do |f| %> <%= labelled_form_for resource, url: user_password_path, html: {method: :put} do |f| %>
<%= f.hidden_field :reset_password_token, label: false %>
<%= f.hidden_field :reset_password_token %> <%= f.password_field :password, label: t(".new_password"), required: true, size: 30,
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.password_field :password, required: true, size: 30, autofocus: true, <%= f.submit t(".update_password") %>
minlength: @minimum_password_length, autocomplete: 'new-password' %> <% end %>
<%= f.password_field :password_confirmation, required: true, size: 30, </div>
minlength: @minimum_password_length, autocomplete: 'off' %>
<%= f.submit t('.update_password') %>
<% end %>

View File

@@ -1,8 +1,7 @@
<%= labeled_form_for resource, url: user_password_path, <div class="main">
html: {class: 'main-area'} do |f| %> <%= labelled_form_for resource, url: user_password_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.submit t(:recover_password) %>
autocomplete: 'email' %> <% end %>
</div>
<%= f.submit t(:recover_password) %>
<% end %>

View File

@@ -1,29 +1,31 @@
<% 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-area buttongrid"> <div class="rightside buttongrid">
<%= image_button_to_if !current_user.sole_admin?, t('.delete'), 'account-remove-outline', <%= image_button_to t(".delete"), "account-remove-outline", user_registration_path,
user_registration_path, form_class: 'tools-area', method: :delete, data: {turbo: false}, form_class: 'tools', method: :delete, data: {turbo: false},
onclick: {confirm: t('.confirm_delete')} %> onclick: {confirm: t(".confirm_delete")} %>
</div> </div>
<%= labeled_form_for resource, url: registration_path(resource), <%= labelled_form_for resource, url: registration_path(resource),
html: {method: :patch, class: 'main-area'} do |f| %> html: {method: :patch, class: 'main'} do |f| %>
<%= f.email_field :email, size: 30, autofocus: true, autocomplete: "off" %>
<%= f.email_field :email, size: 30, autofocus: true, autocomplete: 'off' %> <% if f.object.pending_reconfirmation? %>
<% if resource.pending_reconfirmation? %> <%= f.text_field :unconfirmed_email, readonly: true, tabindex: -1,
<%= f.text_field :unconfirmed_email, readonly: true, hint: t(".unconfirmed_email_hint",
confirmation_sent_at: l(resource.confirmation_sent_at) %> timestamp: f.object.confirmation_sent_at.to_fs(:db_without_sec)) %>
<% end %> <% end %>
<%= f.select :status, User.statuses, readonly: true %> <%= f.select :status, User.statuses, readonly: true %>
<%= f.password_field :password, size: 30, autocomplete: 'new-password', <%= f.password_field :password, label: t(".new_password"), size: 30,
minlength: @minimum_password_length %> minlength: @minimum_password_length, autocomplete: "new-password",
<%= f.password_field :password_confirmation, size: 30, autocomplete: 'off', hint: t(".blank_password_hint_html",
minlength: @minimum_password_length %> subhint: t("users.minimum_password_length", count: @minimum_password_length)) %>
<%= f.password_field :password_confirmation, label: t(".password_confirmation"),
size: 30, minlength: @minimum_password_length, autocomplete: "off" %>
<%= f.submit %> <%= f.submit t(".update") %>
<% end %> <% end %>

View File

@@ -1,16 +1,16 @@
<div class="main-area"> <div class="main">
<%= labeled_form_for resource, url: user_registration_path do |f| %> <%= labelled_form_for resource, url: user_registration_path do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, <%= f.email_field :email, required: true, size: 30, autofocus: true, autocomplete: "email" %>
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",
<%= f.password_field :password_confirmation, required: true, size: 30, hint: t("users.minimum_password_length", count: @minimum_password_length) %>
minlength: @minimum_password_length, autocomplete: 'off' %> <%= f.password_field :password_confirmation, label: t(".password_confirmation"),
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', <%= image_link_to t(:resend_confirmation), "email-sync-outline", new_user_confirmation_path,
new_user_confirmation_path, class: 'centered' %> class: "centered" %>
</div> </div>

View File

@@ -1,18 +1,17 @@
<div class="main-area"> <div class="main">
<%= labeled_form_for resource, url: user_session_path do |f| %> <%= labelled_form_for resource, url: user_session_path do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, <%= f.email_field :email, required: true, size: 30, autofocus: true, autocomplete: "email" %>
autocomplete: 'email' %> <%= f.password_field :password, required: true, size: 30, minlength: @minimum_password_length,
<%= f.password_field :password, required: true, size: 30, autocomplete: "current-password" %>
minlength: @minimum_password_length, autocomplete: 'current-password' %>
<% if devise_mapping.rememberable? %> <% if devise_mapping.rememberable? %>
<%= f.check_box :remember_me %> <%= f.check_box :remember_me, label: t(".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,18 +1,19 @@
<% content_for :navigation, flush: true do %> <% content_for :navigation, flush: true do %>
<%= link_to svg_tag('pictograms/arrow-left-bold-outline', t(:back)), users_path, <div class="left">
class: 'tab' %> <%= image_link_to t(:back), "arrow-left-bold-outline", users_path %>
</div>
<% end %> <% end %>
<%= labeled_form_for @user, html: {class: 'main-area'} do |f| %> <%= labelled_form_for @user do |f| %>
<%= f.email_field :email, readonly: true %> <%= f.email_field :email, readonly: true %>
<% if @user.pending_reconfirmation? %> <% if f.object.pending_reconfirmation? %>
<%= f.email_field :unconfirmed_email, readonly: true, <%= f.email_field :unconfirmed_email, readonly: true,
confirmation_sent_at: l(@user.confirmation_sent_at) %> hint: t("users.registrations.edit.unconfirmed_email_hint",
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, placeholder: t(:no) %> <%= f.text_field :confirmed_at, readonly: true %>
<% end %> <% end %>

View File

@@ -54,9 +54,5 @@ module FixinMe
# Sender address of account registration-related messages # Sender address of account registration-related messages
Devise.mailer_sender = 'noreply@localhost' Devise.mailer_sender = 'noreply@localhost'
# Whether to skip e-mail confirmation for new registrations is configured
# through the web setup wizard and stored in the database (Setting model),
# so it does not need to be set here.
end end
end end

View File

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

View File

@@ -19,19 +19,7 @@ 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,28 +1,22 @@
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:
quantity:
description: Description
name: Name
unit: unit:
base: Base unit
description: Description
multiplier: Multiplier
symbol: Symbol symbol: Symbol
name: Name
multiplier: Multiplier
base: Base unit
user: user:
confirmed_at: Confirmed email: e-mail
created_at: Registered status: status
email: E-mail password: password
status: Status created_at: registered
confirmed_at: confirmed
unconfirmed_email: Awaiting confirmation for
errors: errors:
models: models:
unit: unit:
@@ -59,22 +53,9 @@ 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
@@ -85,12 +66,12 @@ en:
navigation: Measurements navigation: Measurements
no_items: There are no measurements taken. You can Add some now. no_items: There are no measurements taken. You can Add some now.
form: form:
select_quantity: select the measured quantities... select_quantity: select the measured quantity...
index: index:
new_measurement: Add measurement new_measurement: Add measurement
readouts: readouts:
form: form:
select_unit: ... new_children: Children
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.
@@ -148,47 +129,33 @@ en:
disguise: View as disguise: View as
passwords: passwords:
edit: edit:
password_html: 'New password:%{password_length_hint_html}' new_password: New password
password_confirmation: Retype new password
update_password: Update password update_password: Update password
registrations: registrations:
new: new:
password_html: 'Password:%{password_length_hint_html}' password_confirmation: Retype password
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
password_html: > unconfirmed_email_hint: (since %{timestamp})
New password: new_password: New password
<br><em>leave blank to keep unchanged</em> password_confirmation: Retype new password
%{password_length_hint_html} blank_password_hint_html: leave blank to keep unchanged<br>%{subhint}
registrations: update: Update profile
destroy: sessions:
sole_admin: You cannot delete the only admin account. new:
remember_me: Remember me
minimum_password_length:
zero:
one: (%{count} character minimum)
other: (%{count} characters minimum)
actions: Actions actions: Actions
setup:
new:
admin_account: Admin account
admin_email: 'E-mail:'
admin_password: 'Password:'
admin_password_confirmation: 'Retype password:'
options: Options
skip_email_confirmation: Skip e-mail confirmation for new registrations
seed_units: Seed built-in default units
submit: Set up
create:
email_blank: E-mail cannot be blank.
password_blank: Password cannot be blank.
password_mismatch: Passwords do not match.
success: >
Installation complete. You can now sign in with the admin account you
just created.
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,7 +1,4 @@
Rails.application.routes.draw do Rails.application.routes.draw do
# Web-based installation wizard — only reachable when no admin exists yet.
resource :setup, only: [:new, :create], controller: :setup
resources :measurements resources :measurements
resources :readouts, only: [:new] do resources :readouts, only: [: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, on_delete: :cascade} t.references :base, foreign_key: {to_table: :units}
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, on_delete: :cascade} t.references :parent, foreign_key: {to_table: :quantities}
t.timestamps null: false t.timestamps null: false

View File

@@ -1,12 +0,0 @@
class CreateSettings < ActiveRecord::Migration[7.2]
def change
create_table :settings do |t|
t.string :key, null: false
t.string :value
t.timestamps
end
add_index :settings, :key, unique: true
end
end

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", on_delete: :cascade add_foreign_key "quantities", "quantities", column: "parent_id"
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", on_delete: :cascade add_foreign_key "units", "units", column: "base_id"
add_foreign_key "units", "users" add_foreign_key "units", "users"
end end

View File

@@ -3,17 +3,6 @@
# bin/rails db:seed # bin/rails db:seed
# command (or created alongside the database with db:setup). # command (or created alongside the database with db:setup).
# Seeding process should be idempotent. # Seeding process should be idempotent.
#
# Admin account setup
# -------------------
# The preferred way to create the first admin account is through the web setup
# wizard, which is shown automatically on the first visit when no admin exists.
# The wizard also lets you configure runtime options (e.g. skip e-mail
# confirmation) and seed the default units without using the command line.
#
# The block below provides an alternative CLI path for headless / automated
# deployments. It is skipped when an admin account already exists (e.g. after
# the web wizard has run).
User.transaction do User.transaction do
break if User.find_by status: :admin break if User.find_by status: :admin

View File

@@ -1,5 +1,5 @@
Unit.transaction do Unit.transaction do
Unit.defaults.delete_all Unit.defaults.order(Unit.arel_table[:base_id].eq(nil)).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.delete_all Unit.defaults.order(Unit.arel_table[:base_id].eq(nil)).delete_all
units = {} units = {}

View File

@@ -1,6 +1,7 @@
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
@@ -18,8 +19,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), with: user.email fill_in User.human_attribute_name(:email).capitalize, with: user.email
fill_in User.human_attribute_name(:password), with: password fill_in User.human_attribute_name(:password).capitalize, with: password
click_on t(:sign_in) click_on t(:sign_in)
user user
end end
@@ -29,13 +30,6 @@ 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

@@ -1,18 +0,0 @@
require "test_helper"
class RegistrationsControllerTest < ActionDispatch::IntegrationTest
test "sole admin cannot delete account" do
sign_in users(:admin)
delete user_registration_path
assert_redirected_to edit_user_registration_path
assert_equal t("registrations.destroy.sole_admin"), flash[:alert]
assert User.exists?(users(:admin).id)
end
test "non-admin can delete account" do
sign_in users(:alice)
assert_difference ->{ User.count }, -1 do
delete user_registration_path
end
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.delete_all @user.units.order(Unit.arel_table[:base_id].eq(nil)).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,21 +6,15 @@ 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.not_found_in_database', assert_text t("devise.failure.invalid", authentication_keys: User.human_attribute_name(:email))
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
@@ -35,7 +29,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), fill_in User.human_attribute_name(:email).capitalize,
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)
@@ -48,8 +42,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.password_html"), with: new_password fill_in t("users.passwords.edit.new_password"), with: new_password
fill_in t("helpers.label.user.password_confirmation"), with: new_password fill_in t("users.passwords.edit.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
@@ -62,9 +56,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), with: random_email fill_in User.human_attribute_name(:email).capitalize, with: random_email
password = random_password password = random_password
fill_in User.human_attribute_name(:password), with: password fill_in User.human_attribute_name(:password).capitalize, 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
@@ -88,7 +82,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), fill_in User.human_attribute_name(:email).capitalize,
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)
@@ -149,7 +143,7 @@ class UsersTest < ApplicationSystemTestCase
end end
test "delete profile" do test "delete profile" do
user = sign_in user: users.reject(&:admin?).select(&:confirmed?).sample user = sign_in
# TODO: remove condition after root_url changed to different path than # TODO: remove condition after root_url changed to different path than
# profile in routes.rb # profile in routes.rb
unless has_current_path?(edit_user_registration_path) unless has_current_path?(edit_user_registration_path)
@@ -157,17 +151,8 @@ 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") }
assert_current_path new_user_session_path
end end
assert_text t("devise.registrations.destroyed") assert_current_path new_user_session_path
end
test "sole admin cannot delete profile" do
sign_in user: users(:admin)
unless has_current_path?(edit_user_registration_path)
first(:link_or_button, users(:admin).email).click
end
assert find(:button, t("users.registrations.edit.delete"))[:disabled]
end end
test "index forbidden for non admin" do test "index forbidden for non admin" do