9 Commits

Author SHA1 Message Date
f3cb8db1f4 Setup wizard: use labeled-form grid for vertical field layout
Replace the fieldset-based layout with the app's standard
.labeled-form CSS grid so email, password and retype fields
stack vertically (label left, input right) exactly like the
existing sign-in and registration forms.

Section headings and checkbox rows are given explicit grid-column
spans via inline styles so they span the full form width rather
than being constrained to the label column.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:37:49 +00:00
7904ff3ef9 Add web-based installation wizard
Replace the CLI-only setup (db:seed + manual application.rb edits)
with a web wizard shown automatically on first visit when no admin
account exists yet.

SetupController (GET/POST /setup) collects the admin e-mail and
password, a "skip e-mail confirmation" toggle, and an option to
seed the built-in default units.  Once submitted it creates the
admin User, persists the chosen options as Setting records, and
redirects to the sign-in page.

ApplicationController gains a redirect_to_setup_if_needed
before_action that catches every request (including Devise routes)
when no admin exists, so a fresh installation always lands on the
wizard rather than an empty sign-in form.

A new Setting model provides a lightweight key-value store for
runtime options that were previously hard-coded in application.rb
(e.g. skip_email_confirmation).  RegistrationsController now reads
that flag from the database instead of from the application config.

Seeds.rb is kept for headless / automated deployments and skips
admin creation when an admin already exists (idempotent), with a
comment pointing to the web wizard as the preferred path.

Also extends the SQLite nil-limit fix (|| Float::INFINITY) to the
Quantity model, which suffered the same ArgumentError as Unit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 17:19:24 +00:00
9ad922e3a1 Add skip_email_confirmation option; fix SQLite length validation
Introduce config.skip_email_confirmation in application.rb.dist.
When set to true, new registrations are automatically confirmed
without requiring email verification — useful for installations
where outgoing email is not configured or for development/testing.
Implemented by calling skip_confirmation! in build_resource before
the record is saved, so no confirmation email is ever sent.

Also fix ArgumentError raised in length validations when
type_for_attribute(:column).limit returns nil, which happens with
SQLite for string columns that have no explicit limit in the
migration. Guard with || Float::INFINITY so the validation is
effectively skipped when the database imposes no limit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:57:50 +00:00
ea8bff9b3d Bundle update to Rails 7.2.3, Devise 5 2026-02-26 02:39:11 +01:00
80130fb7d1 Allow cascade delete Unit/Quantity
Closes #32
2026-02-22 17:50:43 +01:00
1ba7d29441 Update tests to match labeled form changes 2026-02-22 00:55:21 +01:00
84945fa4b4 Simplify and improve labeled form 2026-02-22 00:53:18 +01:00
675eb0aad8 Optimize styles; clean up <fieldset> 2026-02-03 15:33:37 +01:00
bd1a664caa Measurement form based on select-styled <details> 2026-01-31 17:22:09 +01:00
53 changed files with 1045 additions and 617 deletions

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 148 B

View File

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

View File

@@ -9,6 +9,7 @@ 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
@@ -43,6 +44,16 @@ 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,23 +1,16 @@
class ReadoutsController < ApplicationController class ReadoutsController < ApplicationController
before_action :find_quantity, only: [:new, :discard] before_action :find_quantities, only: [:new]
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
new_quantities = @quantities -= @prev_quantities
case params[:button] # TODO: raise ParameterInvalid if new_quantities.empty?
when 'children' @readouts = current_user.readouts.build(@quantities.map { |q| {quantity: q} })
@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 + new_quantities quantities = @prev_quantities + @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
@@ -31,6 +24,10 @@ 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,8 +1,22 @@
class RegistrationsController < Devise::RegistrationsController class RegistrationsController < Devise::RegistrationsController
before_action :authenticate_user!, only: [:edit, :update, :destroy] before_action :authenticate_user!, only: [:edit, :update, :destroy]
def destroy
# TODO: Disallow/disable deletion for last admin account; update :edit view
super
end
protected protected
def 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

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

View File

@@ -1,17 +1,11 @@
module QuantitiesHelper module QuantitiesHelper
def quantity_option_text(quantity, checked = nil) def quantities_check_boxes
prefix = case checked # Closing <details> on focusout event depends on relatedTarget for internal
when true # actions being non-null. To ensure this, all top-layer elements of
# Use color and gray unicode emoji to assure same width. # ::details-content must accept focus, e.g. <label> needs tabindex="-1" */
# Avoid shapes similar to inputs (chackboxes, radio buttons etc.) collection_check_boxes(nil, :quantity, @quantities, :id, :to_s_with_depth,
# (U+27A1 U+FE0F)/U+1F7E6/U+2705/U+1F499 U+2004 include_hidden: false) do |b|
'💙 ' content_tag :li, b.label(tabindex: -1) { b.check_box + b.text }
when false end
# U+2B1C/U+1F90D U+2004
'🤍 '
else
''
end
sanitize('&emsp;' * quantity.depth + prefix + quantity.name)
end end
end end

View File

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

View File

@@ -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} length: {maximum: type_for_attribute(:name).limit || Float::INFINITY}
validates :description, length: {maximum: type_for_attribute(:description).limit} validates :description, length: {maximum: type_for_attribute(:description).limit || Float::INFINITY}
# 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
@@ -100,6 +100,11 @@ class Quantity < ApplicationRecord
name name
end end
def to_s_with_depth
# em space, U+2003
'' * depth + name
end
def destroyable? def destroyable?
subquantities.empty? subquantities.empty?
end end

20
app/models/setting.rb Normal file
View File

@@ -0,0 +1,20 @@
# 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,7 +3,8 @@ class Unit < ApplicationRecord
belongs_to :user, optional: true belongs_to :user, optional: true
belongs_to :base, optional: true, class_name: "Unit" belongs_to :base, optional: true, class_name: "Unit"
has_many :subunits, class_name: "Unit", inverse_of: :base, dependent: :restrict_with_error has_many :subunits, class_name: "Unit", inverse_of: :base,
dependent: :restrict_with_error
validate if: ->{ base.present? } do validate if: ->{ base.present? } do
errors.add(:base, :user_mismatch) unless user_id == base.user_id errors.add(:base, :user_mismatch) unless user_id == base.user_id
@@ -11,8 +12,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} length: {maximum: type_for_attribute(:symbol).limit || Float::INFINITY}
validates :description, length: {maximum: type_for_attribute(:description).limit} validates :description, length: {maximum: type_for_attribute(:description).limit || Float::INFINITY}
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: :destroy has_many :readouts, dependent: :delete_all
accepts_nested_attributes_for :readouts accepts_nested_attributes_for :readouts
has_many :quantities, dependent: :destroy has_many :quantities, dependent: :delete_all
has_many :units, dependent: :destroy has_many :units, dependent: :delete_all
validates :email, presence: true, uniqueness: true, validates :email, presence: true, uniqueness: true,
length: {maximum: type_for_attribute(:email).limit} length: {maximum: type_for_attribute(:email).limit}

View File

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

View File

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

View File

@@ -1,40 +1,36 @@
<%= tabular_form_with model: Measurement.new do |form| %> <%= tabular_form_with model: Measurement.new, id: :measurement_form,
<fieldset> class: 'topside-area vflex', html: {onkeydown: 'formProcessKey(event)'} do |form| %>
<legend> <table class="items centered">
<%= tag.span t('.no_items'), id: :measurement_form_legend %> <tbody id="readouts"></tbody>
<%= image_link_to '', "pencil-outline", measurements_path,
data: {turbo_stream: true} %>
</legend>
<table class="items">
<tbody id="readouts">
<tr id="readouts_form">
<td>
<%= select_tag :id,
options_from_collection_for_select(
@quantities, :id, ->(q){ quantity_option_text(q, false) }
), class: 'quantity' %>
</td>
<td colspan="3">
<div class="actions">
<% opts = {formaction: new_readout_path, formmethod: :get,
formnovalidate: true, data: {turbo_stream: true}} %>
<%= image_button_tag t('.new_readout'), 'plus-outline', id: :new_readout,
value: nil, **opts -%>
<%= image_button_tag t('.new_children'), 'plus-multiple-outline',
value: :children, **opts -%>
<%#= image_button_tag t('.new_subtree'), 'plus-multiple-outline',
value: :subtree, **opts -%>
<%= image_button_tag t('.new_leaves'), 'plus-multiple-outline',
value: :leaves, **opts -%>
</div>
</td>
</tr>
</tbody>
</table> </table>
</fieldset>
<div class="hflex centered"> <div class="hflex">
<%= form.button -%> <%# TODO: right-click selection %>
<details id="quantity_select" class="hexpand" open
onkeydown="detailsProcessKey(event)">
<summary autofocus>
<!-- TODO: Set content with CSS when span empty to avoid duplication -->
<span data-prompt="<%= t('.select_quantity') %>">
<%= t('.select_quantity') %>
</span>
<%= image_button_tag t(:apply), "update", name: nil, disabled: true,
formaction: new_readout_path, formmethod: :get, formnovalidate: true,
data: {turbo_stream: true} %>
</summary>
<ul><%= quantities_check_boxes %></ul>
</details>
<%= form.button id: :create_measurement_button, disabled: true -%>
</div>
<div class="hflex reverse">
<%= image_link_to t(:cancel), "close-outline", measurements_path, name: :cancel, <%= 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,2 +1,4 @@
<%= 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 buttongrid"> <div class="rightside-area 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,9 +7,7 @@
<% end %> <% end %>
</div> </div>
<%= tag.div class: 'topside', id: :measurement_form %> <table class="main-area">
<table class="main">
<tbody id="measurements"> <tbody id="measurements">
<%= render(@measurements) || render_no_items %> <%= render(@measurements) || render_no_items %>
</tbody> </tbody>

View File

@@ -1,4 +1,5 @@
<%= turbo_stream.disable :new_measurement_link -%> <%= turbo_stream.disable :new_measurement_link -%>
<%= turbo_stream.update :measurement_form do %> <%= turbo_stream.hide :no_items -%>
<%= 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: "processKey(event)", <%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {link: link, form: form_tag, hidden_row: hidden_row} do %> data: {link: link, form: form_tag, hidden_row: hidden_row} do %>
<td style="--depth:<%= @quantity.depth %>"> <td style="--depth:<%= @quantity.depth %>">

View File

@@ -1,20 +1,20 @@
<div class="rightside buttongrid"> <div class="rightside-area buttongrid">
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<%= image_link_to t('.new_quantity'), 'plus-outline', new_quantity_path, <%= image_link_to t('.new_quantity'), 'plus-outline', new_quantity_path,
id: dom_id(Quantity, :new, :link), onclick: 'this.blur();', 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' %> class: 'tools-area' %>
</div> </div>
<%= tag.div class: 'main', id: :quantity_form %> <%= tag.div class: 'main-area', id: :quantity_form %>
<table class="main items"> <table class="main-area items">
<thead> <thead>
<tr> <tr>
<th><%= Quantity.human_attribute_name(:name).capitalize %></th> <th><%= Quantity.human_attribute_name(:name) %></th>
<th><%= Quantity.human_attribute_name(:description).capitalize %></th> <th><%= Quantity.human_attribute_name(:description) %></th>
<% if current_user.at_least(:active) %> <% if current_user.at_least(:active) %>
<th><%= t :actions %></th> <th><%= t :actions %></th>
<th></th> <th></th>

View File

@@ -1,22 +1,25 @@
<%# 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), <%- tag.tr id: dom_id(readout.quantity, :new, :readout) do %>
onkeydown: 'processKey(event)' do %> <td class="actions">
<%# TODO: change to _link_ after giving up displaying relative paths %>
<%= image_button_tag '', 'delete-outline', class: 'dangerous', name: nil,
formaction: discard_readouts_path(readout.quantity),
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>
</td>
<td> <td>
<%= readout.quantity.relative_pathname(@superquantity) %> <%= readout.quantity.relative_pathname(@superquantity) %>
</td> </td>
<td> <td>
<%= form.number_field :value, required: true, autofocus: true, size: 10 %> <%= form.number_field :value, required: true,
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) },
</td> {prompt: t('.select_unit'), disabled: '', selected: ''}, required: true %>
<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,5 +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.update_all "#id option[value=\"#{@quantity.id}\"]",
quantity_option_text(@quantity, false) %>
<%= render partial: 'form_repath' %> <%= render partial: 'form_repath' %>
<%= turbo_stream.unselect dom_id(@quantity) %>

View File

@@ -1,11 +1,8 @@
<%= render partial: 'form_repath' %>
<%= turbo_stream.enable_all 'button[name="discard"]' if @prev_quantities.one? %>
<%# TODO: disable Add actions accordingly (e.g. disable Children if childless or all
children already added), then disable option once all actions unavailable %>
<% @readouts.each do |r| %> <% @readouts.each do |r| %>
<%= turbo_stream.update_all "#id option[value=\"#{r.quantity_id}\"]", <%= turbo_stream.disable dom_id(r.quantity) %>
quantity_option_text(r.quantity, true) %>
<% end %> <% end %>
<%= turbo_stream.before :readouts_form do %> <%= render partial: 'form_repath' %>
<%= turbo_stream.append :readouts 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,5 +54,9 @@ 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

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

View File

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

View File

@@ -1,22 +1,28 @@
en: en:
time:
formats:
# Format contains non-breaking space: 160.chr(Encoding::UTF_8)
default: "%Y-%m-%d %H:%M %Z"
without_tz: "%Y-%m-%d %H:%M"
errors: errors:
messages: messages:
precision_exceeded: must not exceed %{value} significant digits precision_exceeded: must not exceed %{value} significant digits
scale_exceeded: must not exceed %{value} decimal digits scale_exceeded: must not exceed %{value} decimal digits
activerecord: activerecord:
attributes: attributes:
unit: quantity:
symbol: Symbol description: Description
name: Name name: Name
multiplier: Multiplier unit:
base: Base unit base: Base unit
description: Description
multiplier: Multiplier
symbol: Symbol
user: user:
email: e-mail confirmed_at: Confirmed
status: status created_at: Registered
password: password email: E-mail
created_at: registered status: Status
confirmed_at: confirmed
unconfirmed_email: Awaiting confirmation for
errors: errors:
models: models:
unit: unit:
@@ -53,9 +59,22 @@ en:
The request is semantically incorrect and was rejected (422 Unprocessable Entity). The request is semantically incorrect and was rejected (422 Unprocessable Entity).
This should not happen, please notify site administrator. This should not happen, please notify site administrator.
helpers: helpers:
label:
user:
password_confirmation: 'Retype new password:'
password_length_hint_html:
count: '%{minimum_password_length}'
zero:
one: <br><em>(%{count} character minimum)</em>
other: <br><em>(%{count} characters minimum)</em>
remember_me: 'Remember me:'
unconfirmed_email_html: >
Awaiting confirmation for:<br><em>(since %{confirmation_sent_at})</em>
submit: submit:
create: Create create: Create
update: Update update: Update
user:
update: Update profile
layouts: layouts:
application: application:
issue_tracker: Report issue issue_tracker: Report issue
@@ -66,16 +85,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:
new_readout: Add select_quantity: select the measured quantities...
new_children: Children
new_subtree: Subtree
new_leaves: Leaves
no_items: Select and add desired quantities...
select_quantity: select quantity...
index: index:
new_measurement: Add measurement new_measurement: Add measurement
readouts: readouts:
form: form:
select_unit: ...
quantities: quantities:
navigation: Quantities navigation: Quantities
no_items: There are no configured quantities. You can Add some or Import from defaults. no_items: There are no configured quantities. You can Add some or Import from defaults.
@@ -133,33 +148,44 @@ en:
disguise: View as disguise: View as
passwords: passwords:
edit: edit:
new_password: New password password_html: 'New password:%{password_length_hint_html}'
password_confirmation: Retype new password
update_password: Update password update_password: Update password
registrations: registrations:
new: new:
password_confirmation: Retype password password_html: 'Password:%{password_length_hint_html}'
password_confirmation: 'Retype password:'
edit: edit:
confirm_delete: Are you sure you want to delete profile? confirm_delete: Are you sure you want to delete profile?
All data will be irretrievably lost. All data will be irretrievably lost.
delete: Delete profile delete: Delete profile
unconfirmed_email_hint: (since %{timestamp}) password_html: >
new_password: New password New password:
password_confirmation: Retype new password <br><em>leave blank to keep unchanged</em>
blank_password_hint_html: leave blank to keep unchanged<br>%{subhint} %{password_length_hint_html}
update: Update profile
sessions:
new:
remember_me: Remember me
minimum_password_length:
zero:
one: (%{count} character minimum)
other: (%{count} characters minimum)
actions: Actions actions: Actions
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,4 +1,7 @@
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} t.references :base, foreign_key: {to_table: :units, on_delete: :cascade}
t.timestamps null: false t.timestamps null: false
end end

View File

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

View File

@@ -0,0 +1,12 @@
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" add_foreign_key "quantities", "quantities", column: "parent_id", on_delete: :cascade
add_foreign_key "quantities", "users" add_foreign_key "quantities", "users"
add_foreign_key "readouts", "quantities" add_foreign_key "readouts", "quantities"
add_foreign_key "readouts", "units" add_foreign_key "readouts", "units"
add_foreign_key "readouts", "users" add_foreign_key "readouts", "users"
add_foreign_key "units", "units", column: "base_id" add_foreign_key "units", "units", column: "base_id", on_delete: :cascade
add_foreign_key "units", "users" add_foreign_key "units", "users"
end end

View File

@@ -3,6 +3,17 @@
# 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.order(Unit.arel_table[:base_id].eq(nil)).delete_all Unit.defaults.delete_all
units = {} units = {}
<% Unit.defaults.ordered.each do |unit| %> <% Unit.defaults.ordered.each do |unit| %>
<%= "\n" if unit.base.nil? %> <%= "\n" if unit.base.nil? %>

View File

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

View File

@@ -1,7 +1,6 @@
require "test_helper" require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
extend ActionView::Helpers::TranslationHelper
include ActionView::Helpers::UrlHelper include ActionView::Helpers::UrlHelper
# NOTE: geckodriver installed with Firefox, ignore incompatibility warning # NOTE: geckodriver installed with Firefox, ignore incompatibility warning
@@ -19,8 +18,8 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
def sign_in(user: users.select(&:confirmed?).sample, password: randomize_user_password!(user)) def sign_in(user: users.select(&:confirmed?).sample, password: randomize_user_password!(user))
visit new_user_session_url visit new_user_session_url
fill_in User.human_attribute_name(:email).capitalize, with: user.email fill_in User.human_attribute_name(:email), with: user.email
fill_in User.human_attribute_name(:password).capitalize, with: password fill_in User.human_attribute_name(:password), with: password
click_on t(:sign_in) click_on t(:sign_in)
user user
end end
@@ -30,6 +29,13 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
evaluate_script("arguments[0].insertAdjacentHTML('beforeend', '#{button.html_safe}');", after) evaluate_script("arguments[0].insertAdjacentHTML('beforeend', '#{button.html_safe}');", after)
end end
# Allow skipping interpolations when translating for testing purposes
INTERPOLATION_PATTERNS = Regexp.union(I18n.config.interpolation_patterns)
def translate(key, **options)
options.empty? ? super.split(INTERPOLATION_PATTERNS, 2).first : super
end
alias :t :translate
#def assert_stale(element) #def assert_stale(element)
# assert_raises(Selenium::WebDriver::Error::StaleElementReferenceError) { element.tag_name } # assert_raises(Selenium::WebDriver::Error::StaleElementReferenceError) { element.tag_name }
#end #end

View File

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

View File

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