Compare commits

..

45 Commits

Author SHA1 Message Date
cryptogopher f04c8d85be Fix SQLite query compatibility inside WITH clause
Rename model methods to #succeeding.
2026-10-18 02:34:57 +02:00
cryptogopher 9c51887024 Improve index tables
Stretch inputs to column width in place of fixed sizing.
Set minimum column widths for columns with form inputs.
Simplify table operations by removing `thead` and adding sentinel.
Manage empty table display with CSS only.
2026-10-17 19:25:46 +02:00
cryptogopher 2c845f3528 Replace patched #dom_id with #dom_target 2026-10-17 05:06:09 +02:00
cryptogopher f1578fad75 Update and move turbo_stream actions to separate file
Use CSS `visibility` instead of `display` for #show/#hide.
Access data attributes through `dataset` property.
2026-10-17 03:14:35 +02:00
cryptogopher 4d86652247 bundle update 2026-10-04 01:26:16 +02:00
cryptogopher eae0ee9d20 Improve controllers and views 2026-07-28 00:46:33 +02:00
cryptogopher eb2797bd90 Display form errors using custom validity messages 2026-07-28 00:45:08 +02:00
cryptogopher 5d051de666 Check for out of range float values
Remove unsupported attribute `[maxlength]` from `input[number]`.
2026-07-20 21:45:33 +02:00
cryptogopher ef8214cfa7 Improve tests, extend fixtures 2026-07-20 16:38:05 +02:00
cryptogopher 30c80e6e2e Fix js code and comment formatting 2026-07-12 17:20:00 +02:00
cryptogopher 159b4b340f Test Unit using float multiplier
Improve string randomization. Use Capybara table selectors.
2026-07-12 17:20:00 +02:00
cryptogopher 774b1eb19b Use [disabled] attribute only on tags that support it
Add Capybara selector for disabled links.
2026-07-12 17:20:00 +02:00
cryptogopher d4be84875e Use <pre> tag for formatting 2026-07-12 17:19:50 +02:00
cryptogopher 65e5643b13 Remove pictograms directory 2026-07-12 17:17:59 +02:00
cryptogopher b05523dcd6 Change Unit.multiplier type from decimal to float
Remove remnants related to BigDecimal.
2026-07-12 17:16:43 +02:00
cryptogopher 1ab925f6fa Improve Units tests and remove test parallelization
Parallelization produces errors (#92)
2026-05-15 00:21:10 +02:00
cryptogopher 83168092f1 Fix SQLite query compatibility inside WITH clause
SQLite3::SQLException: circular reference
2026-05-15 00:21:01 +02:00
cryptogopher 852e6e7cea Fix test passing blank input into required field 2026-05-15 00:20:28 +02:00
cryptogopher 0481b0f6f1 Allow Capybara test server IP in HostAuthorization 2026-05-07 20:53:18 +02:00
cryptogopher 76b95f7c30 Set default :text type column limit 2026-05-07 18:20:06 +02:00
cryptogopher ca7edb99e1 Add Note 2026-05-07 18:17:30 +02:00
cryptogopher 98044fc04a Remove empty test files 2026-05-05 23:41:08 +02:00
cryptogopher d3a34233b9 Set constraints on Readouts.user_id foreign key
Add Measurements table
2026-05-05 20:36:57 +02:00
cryptogopher 4a36ffc8bf Move migration defaults to ActiveRecord migration strategy
Remove redundant defaults
2026-05-05 20:36:51 +02:00
cryptogopher 78639b4c1e Upgrade Rails to 8.1.3 2026-05-05 20:35:00 +02:00
cryptogopher c4def9cd86 Filter :email field logging only in production env 2026-04-30 18:26:43 +02:00
cryptogopher 3454d3052b Generate admin password on db:seed
Test admin account creation in db:seed
2026-04-30 18:23:00 +02:00
cryptogopher dedea0246f Run tests on multiple database configurations 2026-04-27 17:38:49 +02:00
cryptogopher 3ac3a6f13c Drop Readout.value decimal type in favor of float 2026-04-25 18:20:16 +02:00
cryptogopher 97e7bfedf7 Upgrade Rails to 8.0.5
Install sqlite3
Migrate Rails default settings from 7.2 to 8.0
2026-04-25 04:13:47 +02:00
cryptogopher a05767213c Fix test based on unavailable td.link selector 2026-04-25 04:13:47 +02:00
cryptogopher d002ae0f2c Clean up and improve items-table styling
Closes #9
2026-04-25 04:13:47 +02:00
cryptogopher f87aa9fd3d Improve Measurement form layout and styling 2026-04-25 04:13:47 +02:00
cryptogopher 5ed066ad18 Unify border/outline parameters order 2026-03-06 01:42:32 +01:00
cryptogopher dde4e52f1b Fix form elements styling on hover 2026-03-06 01:33:23 +01:00
cryptogopher a9091d76a8 Merge styles of <a>.button/<button>/<input type="submit"> into .button
Remove flash button
Fix some multi-selector specificity differences
2026-03-04 17:13:11 +01:00
cryptogopher 4175d31b9d Update and format comments 2026-03-03 01:31:44 +01:00
cryptogopher c659201904 Make [disabled] and [hidden] styles !important 2026-03-03 01:14:12 +01:00
cryptogopher 83b064ef3c Merge recover password/resend confirmation forms into sign in/register
Closes #65, #66
2026-03-01 20:04:42 +01:00
cryptogopher ea8bff9b3d Bundle update to Rails 7.2.3, Devise 5 2026-02-26 02:39:11 +01:00
cryptogopher 80130fb7d1 Allow cascade delete Unit/Quantity
Closes #32
2026-02-22 17:50:43 +01:00
cryptogopher 1ba7d29441 Update tests to match labeled form changes 2026-02-22 00:55:21 +01:00
cryptogopher 84945fa4b4 Simplify and improve labeled form 2026-02-22 00:53:18 +01:00
cryptogopher 675eb0aad8 Optimize styles; clean up <fieldset> 2026-02-03 15:33:37 +01:00
cryptogopher bd1a664caa Measurement form based on select-styled <details> 2026-01-31 17:22:09 +01:00
159 changed files with 2849 additions and 1697 deletions
+5 -1
View File
@@ -15,6 +15,9 @@
/config/master.key
/config/puma.rb
# Ignore test database.
/db/test.sqlite3
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
@@ -30,7 +33,7 @@
/tmp/restart.txt
# Ignore user files
# Ignore user files.
/.bash_history
/.byebug_history
/.config
@@ -38,6 +41,7 @@
/.lesshst
/.local
/.mysql_history
/.sqlite_history
/.ssh
/.vim
/.viminfo
+76
View File
@@ -0,0 +1,76 @@
DESIGN
======
Below is a list of design decisions. The justification is to be consulted
whenever a change is considered, to avoid regressions.
### Data type for DB storage of numeric values (`decimal` vs `float`)
* among database engines supported (by Rails), SQLite offers storage of
`decimal` data type with the lowest precision, equal to the precision of
`REAL` type (double precision float value, IEEE 754), but in a floating point
format,
* decimal types in other database engines offer greater precision, but store
data in a fixed point format,
* biology-related values differ by several orders of magnitude; storing them in
fixed point format would only make sense if required precision would be
greater than that offered by floating point format,
* even then, fixed point would mean either bigger memory requirements or
worse precision for numbers close to scale limit,
* for a fixed point format to use the same 8 bytes of storage as IEEE
754, precision would need to be limited to 18 digits (4 bytes/9 digits)
and scale approximately half of that - 9,
* double precision floating point guarantees 15 digits of precision, which
is more than enough for all expected use cases,
* if a decimal string with at most 15 significant digits is converted to
the IEEE 754 double-precision format, giving a normal number, and then
converted back to a decimal string with the same number of digits, the
final result should match the original string,
* if an IEEE 754 double-precision number is converted to a decimal
string with at least 17 significant digits, and then converted back to
double-precision representation, the final result must match the
original number,
* single precision floating point only guarntees 6 digits of precision,
which is estimated to be too low for some use cases (e.g. storing
latitude/longitude with a resolution grater than 100m),
* double precision floating point (IEEE 754) is a standard that ensures
compatibility with majority of database engines,
* the same data format is used internally by Ruby as a `Float`; it
guarantees no conversions between storage and computation,
* as a standard with hardware implementations ensures both: computing
efficiency and hardware/3rd party library compatibility as opposed to Ruby
custom `BigDecimal` type,
* at present, only normalized numbers are used and considered sufficient, in
order to avoid potential issues with the cross-platform compatibility of
subnormal (denormal) numbers,
* in the future, the IEEE 754 decimal64 data type may be considered once it is
supported by database engines.
### Database layer vs application layer data model constraints
* database constraints are the final guard against data integrity corruption,
* they should safeguard against data referential integrity loss under _all_
data (not schema) manipulation scenarios, including application level
logic errors and direct data manipulation (e.g. through `dbconsole`),
* application constraints can be as restrictive as database constraints or more,
but not less, as it doesn't serve any use case,
* proper application level constraints should prevent unhandled database
exception occurences, e.g `ActiveRecord::InvalidForeignKey` for operations
performed through Models (i.e. not `#delete_all` etc.)
### User interface
* values provided by user have to be exactly preserved, avoiding any precision
loss due to e.g. type casting
* when it's not possible to store value without information loss, feedback has to
be given at the time of entering value
* values can be received using either:
* `input[number]` - validates: number format, value against `[min]` and
`[max]` attributes; cannot validate precision (no `[pattern]` support);
or
* `input[text]` - can validate number format and precision with
`[pattern]`; cannot validate range (no `[min]`/`[max]` support),
* none of the above prevents misinterpretation of values between 0 and
Float::MIN as 0.0 - this has to be checked manually,
* assuming that user input with _precision > DIG_ is as rare as in
the range _(0, MIN)_, the decision is to choose input which requires
the least/simplest additional code.
+8 -4
View File
@@ -1,14 +1,14 @@
source "https://rubygems.org"
# The requirement for the Ruby version comes from Rails
gem "rails", "~> 7.2.2"
# The requirement for the Ruby version comes from Rails.
gem "rails", "~> 8.1.4"
gem "sprockets-rails"
gem "puma", "~> 6.0"
gem "sassc-rails"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ mingw mswin x64_mingw jruby ]
# TODO: select db gems automatically
# TODO: select db gems automatically?
# database_config = ERB.new(File.read("config/database.yml")).result
# YAML.load(database_config, aliases: true).values.map { |env| env["adapter"] }.uniq
group :mysql, optional: true do
@@ -21,7 +21,7 @@ group :sqlite, optional: true do
gem "sqlite3", "~> 2.7"
end
gem "devise"
gem "devise", "~> 5.0.4"
gem "importmap-rails"
gem "turbo-rails", "~> 2.0"
@@ -43,3 +43,7 @@ group :test do
gem "capybara"
gem "selenium-webdriver"
end
# TODO: remove after upgrade to next version containing PR:
# https://github.com/ruby-i18n/i18n/pull/755
gem "i18n", git: "https://github.com/ruby-i18n/i18n", branch: "delegate-missing-interpolation-handler"
+178 -151
View File
@@ -1,86 +1,97 @@
GIT
remote: https://github.com/ruby-i18n/i18n
revision: 8850d979a3a32453123ed744b943ead8751ba73b
branch: delegate-missing-interpolation-handler
specs:
i18n (1.15.2)
concurrent-ruby (~> 1.0)
GEM
remote: https://rubygems.org/
specs:
actioncable (7.2.2.1)
actionpack (= 7.2.2.1)
activesupport (= 7.2.2.1)
action_text-trix (2.1.19)
railties
actioncable (8.1.4)
actionpack (= 8.1.4)
activesupport (= 8.1.4)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.2.2.1)
actionpack (= 7.2.2.1)
activejob (= 7.2.2.1)
activerecord (= 7.2.2.1)
activestorage (= 7.2.2.1)
activesupport (= 7.2.2.1)
actionmailbox (8.1.4)
actionpack (= 8.1.4)
activejob (= 8.1.4)
activerecord (= 8.1.4)
activestorage (= 8.1.4)
activesupport (= 8.1.4)
mail (>= 2.8.0)
actionmailer (7.2.2.1)
actionpack (= 7.2.2.1)
actionview (= 7.2.2.1)
activejob (= 7.2.2.1)
activesupport (= 7.2.2.1)
actionmailer (8.1.4)
actionpack (= 8.1.4)
actionview (= 8.1.4)
activejob (= 8.1.4)
activesupport (= 8.1.4)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.2.2.1)
actionview (= 7.2.2.1)
activesupport (= 7.2.2.1)
actionpack (8.1.4)
actionview (= 8.1.4)
activesupport (= 8.1.4)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.2)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.2.1)
actionpack (= 7.2.2.1)
activerecord (= 7.2.2.1)
activestorage (= 7.2.2.1)
activesupport (= 7.2.2.1)
actiontext (8.1.4)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.4)
activerecord (= 8.1.4)
activestorage (= 8.1.4)
activesupport (= 8.1.4)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.2.1)
activesupport (= 7.2.2.1)
actionview (8.1.4)
activesupport (= 8.1.4)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.2.1)
activesupport (= 7.2.2.1)
activejob (8.1.4)
activesupport (= 8.1.4)
globalid (>= 0.3.6)
activemodel (7.2.2.1)
activesupport (= 7.2.2.1)
activerecord (7.2.2.1)
activemodel (= 7.2.2.1)
activesupport (= 7.2.2.1)
activemodel (8.1.4)
activesupport (= 8.1.4)
activerecord (8.1.4)
activemodel (= 8.1.4)
activesupport (= 8.1.4)
timeout (>= 0.4.0)
activestorage (7.2.2.1)
actionpack (= 7.2.2.1)
activejob (= 7.2.2.1)
activerecord (= 7.2.2.1)
activesupport (= 7.2.2.1)
activestorage (8.1.4)
actionpack (= 8.1.4)
activejob (= 8.1.4)
activerecord (= 8.1.4)
activesupport (= 8.1.4)
marcel (~> 1.0)
activesupport (7.2.2.1)
activesupport (8.1.4)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
base64 (0.2.0)
bcrypt (3.1.20)
benchmark (0.4.0)
bigdecimal (3.1.9)
uri (>= 0.13.1)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
base64 (0.3.0)
bcrypt (3.1.22)
bigdecimal (4.1.3)
bindex (0.8.1)
builder (3.3.0)
byebug (12.0.0)
byebug (13.0.0)
reline (>= 0.6.0)
capybara (3.40.0)
addressable
matrix
@@ -90,139 +101,155 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
concurrent-ruby (1.3.5)
connection_pool (2.5.2)
crass (1.0.6)
date (3.4.1)
devise (4.9.4)
concurrent-ruby (1.3.8)
connection_pool (3.0.2)
crass (1.0.7)
date (3.5.1)
devise (5.0.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
railties (>= 7.0)
responders
warden (~> 1.2.3)
drb (2.2.1)
drb (2.2.3)
erb (6.0.7)
erubi (1.13.1)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu)
ffi (1.17.2-arm-linux-musl)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl)
globalid (1.2.1)
ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.4-arm-linux-gnu)
ffi (1.17.4-arm-linux-musl)
ffi (1.17.4-arm64-darwin)
ffi (1.17.4-x86_64-darwin)
ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.4-x86_64-linux-musl)
globalid (1.4.0)
activesupport (>= 6.1)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
importmap-rails (2.1.0)
importmap-rails (2.2.3)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.0)
irb (1.15.2)
io-console (0.9.4)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (3.0.2)
logger (1.7.0)
loofah (2.24.0)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
mail (2.9.1)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.0.4)
matrix (0.4.2)
marcel (1.2.1)
matrix (0.4.3)
mini_mime (1.1.5)
minitest (5.25.5)
mysql2 (0.5.6)
net-imap (0.5.7)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
mysql2 (0.5.7)
bigdecimal
net-imap (0.6.7)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
net-protocol (0.4.0)
timeout
net-smtp (0.5.1)
net-smtp (0.5.2)
net-protocol
nio4r (2.7.4)
nokogiri (1.18.8-aarch64-linux-gnu)
nio4r (2.7.5)
nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.8-aarch64-linux-musl)
nokogiri (1.19.4-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.8-arm-linux-gnu)
nokogiri (1.19.4-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.8-arm-linux-musl)
nokogiri (1.19.4-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.8-arm64-darwin)
nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.8-x86_64-darwin)
nokogiri (1.19.4-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-gnu)
nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-musl)
nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4)
orm_adapter (0.5.0)
pg (1.5.9)
pp (0.6.2)
pg (1.7.0)
pg (1.7.0-aarch64-linux)
pg (1.7.0-aarch64-linux-musl)
pg (1.7.0-arm64-darwin)
pg (1.7.0-x86_64-darwin)
pg (1.7.0-x86_64-linux)
pg (1.7.0-x86_64-linux-musl)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
psych (5.2.3)
date
stringio
public_suffix (6.0.1)
puma (6.6.0)
prism (1.9.0)
public_suffix (7.0.5)
puma (6.6.1)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.1.13)
rack-session (2.1.0)
rack (3.2.7)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.2.1)
rackup (2.3.1)
rack (>= 3)
rails (7.2.2.1)
actioncable (= 7.2.2.1)
actionmailbox (= 7.2.2.1)
actionmailer (= 7.2.2.1)
actionpack (= 7.2.2.1)
actiontext (= 7.2.2.1)
actionview (= 7.2.2.1)
activejob (= 7.2.2.1)
activemodel (= 7.2.2.1)
activerecord (= 7.2.2.1)
activestorage (= 7.2.2.1)
activesupport (= 7.2.2.1)
rails (8.1.4)
actioncable (= 8.1.4)
actionmailbox (= 8.1.4)
actionmailer (= 8.1.4)
actionpack (= 8.1.4)
actiontext (= 8.1.4)
actionview (= 8.1.4)
activejob (= 8.1.4)
activemodel (= 8.1.4)
activerecord (= 8.1.4)
activestorage (= 8.1.4)
activesupport (= 8.1.4)
bundler (>= 1.15.0)
railties (= 7.2.2.1)
rails-dom-testing (2.2.0)
railties (= 8.1.4)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
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)
actionpack (= 7.2.2.1)
activesupport (= 7.2.2.1)
railties (8.1.4)
actionpack (= 8.1.4)
activesupport (= 8.1.4)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rake (13.2.1)
rdoc (6.13.1)
psych (>= 4.0.0)
regexp_parser (2.10.0)
reline (0.6.1)
rake (13.4.2)
rbs (4.2.0)
logger
prism (>= 1.6.0)
tsort
rdoc (8.1.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
regexp_parser (2.13.1)
reline (0.7.0)
io-console (~> 0.5)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
rexml (3.4.1)
rubyzip (2.4.1)
responders (3.2.1)
actionpack (>= 7.0)
railties (>= 7.0)
rubyzip (3.7.0)
sassc (2.4.0)
ffi (~> 1.9)
sassc-rails (2.1.2)
@@ -232,53 +259,52 @@ GEM
sprockets-rails
tilt
securerandom (0.4.1)
selenium-webdriver (4.31.0)
selenium-webdriver (4.50.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sprockets (4.2.2)
concurrent-ruby (~> 1.0)
sprockets (4.4.1)
concurrent-ruby (~> 1.1)
logger
rack (>= 2.2.4, < 4)
sprockets-rails (3.5.2)
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
sqlite3 (2.7.3-aarch64-linux-gnu)
sqlite3 (2.7.3-aarch64-linux-musl)
sqlite3 (2.7.3-arm-linux-gnu)
sqlite3 (2.7.3-arm-linux-musl)
sqlite3 (2.7.3-arm64-darwin)
sqlite3 (2.7.3-x86_64-darwin)
sqlite3 (2.7.3-x86_64-linux-gnu)
sqlite3 (2.7.3-x86_64-linux-musl)
stringio (3.1.7)
thor (1.3.2)
tilt (2.6.0)
timeout (0.4.3)
turbo-rails (2.0.13)
sqlite3 (2.9.6-aarch64-linux-gnu)
sqlite3 (2.9.6-aarch64-linux-musl)
sqlite3 (2.9.6-arm-linux-gnu)
sqlite3 (2.9.6-arm-linux-musl)
sqlite3 (2.9.6-arm64-darwin)
sqlite3 (2.9.6-x86_64-darwin)
sqlite3 (2.9.6-x86_64-linux-gnu)
sqlite3 (2.9.6-x86_64-linux-musl)
thor (1.5.0)
tilt (2.9.0)
timeout (0.6.1)
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
uri (1.1.1)
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
web-console (4.3.0)
actionview (>= 8.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
railties (>= 8.0.0)
websocket (1.2.11)
websocket-driver (0.7.7)
websocket-driver (0.8.2)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.2)
zeitwerk (2.8.3)
PLATFORMS
aarch64-linux-gnu
@@ -293,12 +319,13 @@ PLATFORMS
DEPENDENCIES
byebug
capybara
devise
devise (~> 5.0.4)
i18n!
importmap-rails
mysql2 (~> 0.5)
pg (~> 1.5)
puma (~> 6.0)
rails (~> 7.2.2)
rails (~> 8.1.4)
sassc-rails
selenium-webdriver
sprockets-rails
+43 -17
View File
@@ -16,14 +16,10 @@ environment, see the _Contributing_ section below.
* Server side:
* Ruby interpreter, depending on the version of Rails used (see _Gemfile_),
* https://guides.rubyonrails.org/upgrading_ruby_on_rails.html#ruby-versions
* database (e.g. MySQL >= 8.0) supporting:
* database (e.g. SQLite, MySQL >= 8.0) supporting:
* recursive Common Table Expressions (CTE) for SELECT/UPDATE/DELETE,
* MariaDB does not support CTE for UPDATE/DELETE
(https://jira.mariadb.org/browse/MDEV-18511)
* decimal datatype with precision of at least 30,
* SQLite3 _flexible typing_ decimal will work, but precision
will be limited to 16, making it practical mostly for testing
purposes
* for testing: browser as specified in _Client side_ requirements
* Client side:
* browser (e.g. Firefox >= 121) supporting:
@@ -89,14 +85,10 @@ Running
### Standalone Rails server + Apache proxy
Customize Puma config template:
Copy and customize Puma config template if required:
cp -a config/puma.rb.dist config/puma.rb
and specify server IP/port, either with `port` or `bind`, e.g.:
bind 'tcp://0.0.0.0:3000'
#### (option 1) Start server manually
bundle exec rails s -e production
@@ -123,9 +115,26 @@ Contributing
### Gems
Apart from database adapter, install development and testing gems:
Install development and testing gems, including at least MySQL and SQLite
database adapters:
bundle config --local with mysql development test
bundle config --local with development test mysql sqlite
#### Using gems from local git repos
When working on gem PRs, it may be useful to work with proposed changes locally
until PRs get accepted upstream. To add gem hosted in local git repository:
bundle config set local.capybara /var/www/tmp/capybara
Add gem to `Gemfile`:
gem "capybara", git: "https://github.com/teamcapybara/capybara", branch: "branch-name"
and install:
bundle install
bundle show capybara
### Configuration
@@ -138,20 +147,29 @@ assets.
### Database
Grant database user privileges for development and test environments,
possibly with different Ruby versions:
Grant database user privileges for development and test environments. Example
below shows how to grant privileges to all databases which names start with
`fixinme-` on MySQL:
> mysql -p
mysql> create user `fixinme-dev`@localhost identified by '<some password>';
mysql> grant all privileges on `fixinme-%`.* to `fixinme-dev`@localhost;
mysql> flush privileges;
Dumping development data before database reset:
mysqldump -h <address> -u <user> -p --no-create-info --no-tablespaces --complete-insert <database> > tmp/data.sql
### Development environment
Starting application server in development environment:
bundle exec rails s -e development
Accessing database console when more than one test db is present:
bundle exec rails dbconsole -e test --db sqlite3
For running rake tasks, prepend command with environment:
RAILS_ENV=development bundle exec rails ...
@@ -164,14 +182,22 @@ Tests need to be run from within toplevel application directory:
bundle exec rails test:system
* system test(s) with seed/test name specified:
* system test(s) with seed or test name specified:
bundle exec rails test:system --seed 64690 --name test_add_unit
bundle exec rails test:system --include test_add_unit --seed 64690
* all tests from one file, with optional seed:
* all tests from one file, optionally with seed:
bundle exec rails test test/system/users_test.rb --seed 1234
* system tests for selected database configuration (if multiple present):
bundle exec rails test:system --include /^test_sqlite3_/
* single system test for all database configurations (if multiple present):
bundle exec rails test:system --include /^test_\\w+_add_unit$/
### Icons
Pictogrammers Material Design Icons: https://pictogrammers.com/library/mdi/

Before

Width:  |  Height:  |  Size: 432 B

After

Width:  |  Height:  |  Size: 432 B

Before

Width:  |  Height:  |  Size: 432 B

After

Width:  |  Height:  |  Size: 432 B

Before

Width:  |  Height:  |  Size: 524 B

After

Width:  |  Height:  |  Size: 524 B

Before

Width:  |  Height:  |  Size: 618 B

After

Width:  |  Height:  |  Size: 618 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M12,2L1,21H23M12,6L19.53,19H4.47M11,10V14H13V10M11,16V18H13V16" /></svg>

After

Width:  |  Height:  |  Size: 152 B

Before

Width:  |  Height:  |  Size: 151 B

After

Width:  |  Height:  |  Size: 151 B

Before

Width:  |  Height:  |  Size: 236 B

After

Width:  |  Height:  |  Size: 236 B

Before

Width:  |  Height:  |  Size: 603 B

After

Width:  |  Height:  |  Size: 603 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M12 20C7.59 20 4 16.41 4 12S7.59 4 12 4 20 7.59 20 12 16.41 20 12 20M16.59 7.58L10 14.17L7.41 11.59L6 13L10 17L18 9L16.59 7.58Z" /></svg>

After

Width:  |  Height:  |  Size: 278 B

Before

Width:  |  Height:  |  Size: 231 B

After

Width:  |  Height:  |  Size: 231 B

+1
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

Before

Width:  |  Height:  |  Size: 382 B

After

Width:  |  Height:  |  Size: 382 B

Before

Width:  |  Height:  |  Size: 330 B

After

Width:  |  Height:  |  Size: 330 B

Before

Width:  |  Height:  |  Size: 350 B

After

Width:  |  Height:  |  Size: 350 B

Before

Width:  |  Height:  |  Size: 189 B

After

Width:  |  Height:  |  Size: 189 B

Before

Width:  |  Height:  |  Size: 192 B

After

Width:  |  Height:  |  Size: 192 B

Before

Width:  |  Height:  |  Size: 174 B

After

Width:  |  Height:  |  Size: 174 B

Before

Width:  |  Height:  |  Size: 597 B

After

Width:  |  Height:  |  Size: 597 B

Before

Width:  |  Height:  |  Size: 216 B

After

Width:  |  Height:  |  Size: 216 B

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Before

Width:  |  Height:  |  Size: 771 B

After

Width:  |  Height:  |  Size: 771 B

Before

Width:  |  Height:  |  Size: 671 B

After

Width:  |  Height:  |  Size: 671 B

Before

Width:  |  Height:  |  Size: 220 B

After

Width:  |  Height:  |  Size: 220 B

Before

Width:  |  Height:  |  Size: 218 B

After

Width:  |  Height:  |  Size: 218 B

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

After

Width:  |  Height:  |  Size: 316 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path fill="#ffffff" d="M12,2L1,21H23M12,6L19.53,19H4.47M11,10V14H13V10M11,16V18H13V16" /></svg>

Before

Width:  |  Height:  |  Size: 167 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path fill="#ffffff" d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M12 20C7.59 20 4 16.41 4 12S7.59 4 12 4 20 7.59 20 12 16.41 20 12 20M16.59 7.58L10 14.17L7.41 11.59L6 13L10 17L18 9L16.59 7.58Z" /></svg>

Before

Width:  |  Height:  |  Size: 293 B

Before

Width:  |  Height:  |  Size: 297 B

After

Width:  |  Height:  |  Size: 297 B

Before

Width:  |  Height:  |  Size: 197 B

After

Width:  |  Height:  |  Size: 197 B

Before

Width:  |  Height:  |  Size: 165 B

After

Width:  |  Height:  |  Size: 165 B

Before

Width:  |  Height:  |  Size: 242 B

After

Width:  |  Height:  |  Size: 242 B

Before

Width:  |  Height:  |  Size: 423 B

After

Width:  |  Height:  |  Size: 423 B

Before

Width:  |  Height:  |  Size: 173 B

After

Width:  |  Height:  |  Size: 173 B

Before

Width:  |  Height:  |  Size: 475 B

After

Width:  |  Height:  |  Size: 475 B

+437 -338
View File
@@ -15,6 +15,16 @@
*= 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>, <table>, <a> as link/button),
* * styles with multiple selectors should have all selectors with same
* specificity, to allow proper rule specificity vs order management.
*
* NOTE: style in a modular way, similar to how CSS @scope would be used,
* to make transition easier once @scope is widely available. */
/* TODO: change :root -> <html> ? */
:root {
--color-focus-gray: #f3f3f3;
--color-border-gray: #dddddd;
@@ -46,50 +56,173 @@
:focus-visible {
outline: none;
}
/* TODO: collapse gaps around empty rows (`topside`) once possible
* https://github.com/w3c/csswg-drafts/issues/5813 */
body {
display: grid;
gap: 0.8em;
grid-template-areas:
"header header header"
"nav nav nav"
"leftempty topside rightempty"
"leftside main rightside";
grid-template-columns: 1fr auto 1fr;
grid-template-rows: repeat(4, auto);
font-family: system-ui;
margin: 0.4em;
/* NOTE: move to higher priority layer instead of using !important?; add CSS
* @layer requirements in README */
/* Disable elements with:
* `[aria-disabled=true]` - non-form elements (e.g. links),
* `[disabled]` - form controls, that may be enabled conditionally,
* `[readonly]` - form controls, that stay disabled for form lifetime. */
[aria-disabled=true],
[disabled] {
border-color: var(--color-border-gray) !important;
color: var(--color-border-gray) !important;
/* NOTE: cannot set cursor when `pointer-events: none`; can be fixed by setting
* `cursor` on wrapping element.
cursor: not-allowed; */
fill: var(--color-border-gray) !important;
pointer-events: none !important;
}
/* Styles set `display` without distinguishing between [hidden] elements, making
* them visible. */
[hidden] {
display: none !important;
}
/* Color coding of input controls' background:
* blue - target for interaction with pointer,
* gray - target for interaction with keyboard,
* red - destructive, non-undoable action.
*/
/* TODO: merge selectors using :is() */
a,
button,
details,
input,
select,
summary,
textarea {
background-color: inherit;
font: inherit;
}
details,
input,
select {
text-align: inherit;
}
input,
select,
summary,
textarea {
border: 1px solid var(--color-gray);
border-radius: 0.25em;
padding: 0.2em 0.4em;
}
svg {
height: 1.4em;
margin: 0 0.2em 0 0;
width: 1.4em;
}
svg:last-child {
margin-right: 0;
}
textarea {
margin: 0;
}
input[type=checkbox] {
accent-color: var(--color-blue);
appearance: none;
-webkit-appearance: none;
display: flex;
height: 1.1em;
margin: 0;
padding: 0;
width: 1.1em;
}
input[type=checkbox]:checked {
appearance: checkbox;
-webkit-appearance: checkbox;
}
/* Hide spin buttons of <input type=number>. */
/* TODO: add spin buttons inside <input type=number>: before (-) and after (+) input. */
input[type=number] {
appearance: textfield;
-moz-appearance: textfield;
text-align: end;
}
input::-webkit-inner-spin-button {
-webkit-appearance: none;
}
input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Text color of table form controls:
* - black for row/table forms,
* - inherited for internal (column specific) buttons/forms. */
table input,
table select,
table summary,
table textarea {
border-color: var(--color-border-gray);
}
table input,
table select,
table textarea {
padding-block: 0.375em;
}
table form input,
table form select,
table form summary,
table form textarea {
color: inherit;
}
table svg:not(:only-child) {
height: 1.25em;
width: 1.25em;
}
input:focus-visible,
/* TODO: select:focus-visible is redundant with -within? */
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);
color: black;
}
input:hover,
select:hover,
summary:hover,
textarea:hover {
border-color: var(--color-blue);
outline: 1px solid var(--color-blue);
}
select:hover,
summary:hover {
color: black;
cursor: pointer;
}
/* TODO: style <details>/<summary> focus to match <select> as much as possible.
summary:focus-visible::before,
summary:hover::before {
background-color: black;
}
*/
input:invalid,
select:invalid,
textarea:invalid {
border-color: var(--color-red);
outline-color: var(--color-red);
}
pre {
display: inline;
font: inherit;
}
/* blue - target for interaction with pointer */
/* gray - target for interaction with keyboard */
/* TODO: remove non-font-size rems from buttons/inputs below */
a,
button,
input[type=submit] {
/* `.button`: button-styled <a>, <button>, <input type=submit>.
* `.link`: any other <a>.
* `.tab`: tab-styled <a>.
*/
.button,
.link,
.tab {
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
/* [hidden] submit controls cannot have `display` set as it makes them visible */
.button,
button:not([hidden]),
input[type=submit]:not([hidden]),
.tab {
align-items: center;
color: var(--color-gray);
@@ -97,66 +230,35 @@ input[type=submit]:not([hidden]),
fill: var(--color-gray);
font-weight: bold;
}
.button,
button,
input[type=submit] {
.button {
border: 1px solid var(--color-gray);
border-radius: 0.25em;
font-size: 0.8rem;
padding: 0.4em;
padding: 0.6em 0.5em;
width: fit-content;
}
input:not([type=submit]):not([type=checkbox]),
select,
textarea {
padding: 0.2em 0.4em;
.link {
color: inherit;
text-decoration: underline 1px var(--color-border-gray);
text-underline-offset: 0.25em;
}
.button,
button,
input,
select,
textarea {
border: solid 1px var(--color-gray);
border-radius: 0.25em;
.auxiliary {
border-color: var(--color-nav-gray);
color: var(--color-nav-gray);
fill: var(--color-nav-gray);
}
fieldset,
textarea {
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 {
table .button {
border-color: var(--color-border-gray);
font-weight: normal;
height: 100%;
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 {
.tab:focus-visible,
.tab:hover {
background-color: var(--color-focus-gray);
}
.button:hover,
button:hover,
input[type=submit]:hover {
.button:hover {
background-color: var(--color-blue);
border-color: var(--color-blue);
color: white;
@@ -166,68 +268,42 @@ input[type=submit]:hover {
background-color: var(--color-red);
border-color: var(--color-red);
}
input[type=checkbox] {
accent-color: var(--color-blue);
appearance: none;
-webkit-appearance: none;
display: flex;
height: 1.1rem;
margin: 0;
width: 1.1rem;
.link:focus-visible {
text-decoration-color: var(--color-gray);
}
input[type=checkbox]:checked {
appearance: checkbox;
-webkit-appearance: checkbox;
}
/* Hide spin buttons in input number fields */
input[type=number] {
appearance: textfield;
-moz-appearance: textfield;
}
input::-webkit-inner-spin-button {
-webkit-appearance: none;
}
input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input:hover,
select:hover,
textarea:hover {
border-color: var(--color-blue);
outline: solid 1px var(--color-blue);
}
input:invalid,
select:invalid,
textarea:invalid {
border-color: 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,
textarea:read-only {
border: none;
padding-left: 0;
padding-right: 0;
.link:hover {
color: var(--color-blue);
text-decoration-color: var(--color-blue);
}
/* NOTE: collapse gaps around empty rows (`topside`) once possible with
* `grid-collapse` property and remove alternative `grid-template-areas`.
* https://github.com/w3c/csswg-drafts/issues/5813 */
body {
display: grid;
gap: 0.8em;
grid-template-areas:
"header header header"
"nav nav nav"
"leftside main rightside";
grid-template-columns: 1fr minmax(max-content, 2fr) 1fr;
font-family: system-ui;
margin: 0.4em;
}
body:has(> .topside-area) {
grid-template-areas:
"header header header"
"nav nav nav"
"leftside topside rightside"
"leftside main rightside";
}
header {
grid-area: header;
}
/* TODO: change .navigation -> <nav> ? */
.navigation {
display: flex;
grid-area: nav;
@@ -236,37 +312,31 @@ header {
margin-inline-start: 4%;
}
.navigation > .tab {
border-bottom: solid 2px var(--color-nav-gray);
border-bottom: 2px solid var(--color-nav-gray);
flex: 1;
font-size: 1rem;
justify-content: center;
padding-block: 0.3em;
}
.navigation > .tab:hover,
.navigation > .tab:focus-visible {
background-color: var(--color-focus-gray);
padding-block: 0.4em;
}
.navigation > .tab.active {
border-bottom: solid 4px var(--color-blue);
border-bottom: 4px solid var(--color-blue);
color: var(--color-blue);
fill: var(--color-blue);
}
.topside {
.topside-area {
grid-area: topside;
}
.leftside {
.leftside-area {
grid-area: leftside;
}
.main {
.main-area {
grid-area: main;
}
.rightside {
.rightside-area {
grid-area: rightside;
}
.buttongrid {
display: grid;
gap: 0.4em;
@@ -274,14 +344,14 @@ header {
grid-template-columns: auto 1fr auto;
grid-template-rows: max-content;
}
.tools {
.tools-area {
grid-area: tools;
}
#flashes {
display: grid;
gap: 0.2em;
row-gap: 0.4em;
grid-template-columns: 1fr auto auto auto 1fr;
left: 0;
pointer-events: none;
@@ -297,281 +367,310 @@ header {
display: grid;
grid-column: 2/5;
grid-template-columns: subgrid;
line-height: 2.2em;
pointer-events: auto;
}
.flash.alert:before {
content: url('pictograms/alert-outline.svg');
.flash::before {
filter: invert(1);
height: 1.4em;
margin: 0 0.5em;
width: 1.4em;
}
.flash.alert::before {
content: url('alert-outline.svg');
}
.flash.alert {
border-color: var(--color-red);
background-color: var(--color-red);
}
.flash.notice:before {
content: url('pictograms/check-circle-outline.svg');
height: 1.4em;
margin: 0 0.5em;
width: 1.4em;
.flash.notice::before {
content: url('check-circle-outline.svg');
}
.flash.notice {
border-color: var(--color-blue);
background-color: var(--color-blue);
}
.flash > div {
grid-column: 2;
}
/* NOTE: currently flash button inherits some unnecessary styles from generic
* button. */
.flash > button {
border: none;
color: inherit;
.flash svg {
cursor: pointer;
font-size: 1.4em;
font-weight: bold;
grid-column: 3;
fill: white;
height: 2.2em;
opacity: 0.6;
padding: 0.2em 0.4em;
padding: 0.4em 0.5em;
width: 2.4em;
}
.flash > button:hover {
.flash svg:hover {
opacity: 1;
}
/* TODO: Update styling, including rem removal. */
form table {
border-spacing: 0.8rem;
.labeled-form {
align-items: center;
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);
font-size: 0.9rem;
padding-right: 0.25rem;
grid-column: 1;
text-align: right;
white-space: nowrap;
}
form label.required {
.labeled-form label.required {
font-weight: bold;
}
form label.error,
form td.error::after {
/* Don't style `label.error + input` if case already covered by `input:invalid`. */
.labeled-form label.error {
color: var(--color-red);
}
form td.error {
display: -webkit-box;
}
form td.error::after {
content: attr(data-content);
font-size: 0.9rem;
margin-left: 1rem;
padding: 0.25rem 0;
position: absolute;
}
form em {
.labeled-form em {
color: var(--color-text-gray);
font-size: 0.75rem;
font-weight: normal;
}
form input[type=submit] {
float: none;
.labeled-form input {
grid-column: 2;
}
.labeled-form input[type=submit] {
font-size: 1rem;
margin: 1.5rem auto 0 auto;
padding: 0.75rem;
margin: 1em auto 0 auto;
padding: 0.75em;
}
.labeled-form .auxiliary {
grid-column: 3;
/* If more buttons are needed, `grid-row` can be replaced with
* `reading-flow: grid-columns` to ensure proper [tabindex] order. */
grid-row: 1;
height: 100%;
padding-block: 0;
}
.tabular-form table {
border: none;
border-spacing: 0;
}
.tabular-form table td {
border: none;
padding-inline-start: 0.4em;
vertical-align: middle;
}
.tabular-form table td:first-child {
padding-inline-start: 0;
}
.tabular-form table td:last-child {
padding-inline-end: 0;
}
.tabular-form table :is(form, input, select, textarea):only-child {
margin-inline-start: 0;
}
table.items {
border-spacing: 0;
border: solid 1px var(--color-border-gray);
.items-table {
border: none;
border-radius: 0.25em;
border-spacing: 0;
font-size: 0.85rem;
text-align: left;
}
table.items thead {
font-size: 0.8rem;
.items-table:has(tbody > tr > td) {
border: 1px solid var(--color-border-gray);
}
table.items thead,
table.items tbody tr:hover {
.items-table:has(tbody > tr > td) caption {
display: none;
}
.items-table tbody:not(:has(tr > td)){
visibility: collapse;
}
.items-table th,
.items-table tr:where(:hover) {
background-color: var(--color-focus-gray);
}
table.items th {
padding-block: 0.75em;
.items-table th {
font-size: 0.8rem;
padding: 0.75em 0 0.75em 1em;
text-align: center;
}
table.items th,
table.items td {
padding-inline: 1em 0;
}
/* For <a> to fill <td> completely, we use an ::after pseudoelement. */
table.items td.link {
padding: 0;
position: relative;
}
table.items td.link a {
color: inherit;
font: inherit;
}
table.items td.link a::after {
content: '';
inset: 0;
position: absolute;
}
table.items td:first-child {
padding-inline-start: calc(1em + var(--depth) * 0.8em);
}
table.items td:has(input, textarea) {
padding-inline-start: calc(0.6em - 0.9px);
}
table.items td:first-child:has(input, textarea) {
padding-inline-start: calc(0.6em + var(--depth) * 0.8em - 0.9px);
}
table.items th:last-child {
.items-table th:last-child {
padding-inline-end: 0.4em;
}
table.items td:last-child {
.items-table td {
border-top: 1px solid var(--color-border-gray);
height: 2.4em;
padding: 0.1em 0 0.1em calc(1em + var(--depth) * 0.8em);
}
.items-table td:last-child {
padding-inline-end: 0.1em;
}
table.items td {
border-top: solid 1px var(--color-border-gray);
height: 2.4em;
padding-block: 0.1em;
.items-table :is(form, input, select, textarea):only-child {
margin-inline-start: calc(-0.4em - 0.9px);
}
table.items .actions {
display: flex;
/* For <a> to fill table cell completely, we use an `::after` pseudoelement. */
/* TODO: expand to whole row? will require adjusting z-index on inputs/buttons */
.items-table td:has(> .link) {
position: relative;
}
.items-table .link::after {
content: '';
inset: -1px 0 0 0;
position: absolute;
}
.items-table .flex {
gap: 0.4em;
justify-content: end;
}
table.items .actions.centered {
justify-content: center;
}
table.items tr.dropzone {
.items-table .dropzone {
position: relative;
}
table.items tr.dropzone::after {
.items-table .dropzone::after {
content: '';
inset: 1px 0 0 0;
position: absolute;
outline: dashed 2px var(--color-blue);
outline: 2px dashed var(--color-blue);
outline-offset: -1px;
z-index: var(--z-index-table-row-outline);
}
table.items td.handle {
cursor: move;
.items-table .handle {
cursor: grab;
}
table.items tr.form td {
.items-table .form td {
vertical-align: top;
}
/* TODO: replace :hover:focus-visible combos with proper LOVE stye order */
/* TODO: Update styling, including rem removal. */
table.items td.link a:hover,
table.items td.link a:focus-visible,
table.items td.link a:hover:focus-visible {
text-decoration: underline;
text-decoration-thickness: 0.05rem;
text-underline-offset: 0.2rem;
}
table.items td.link a:hover {
color: var(--color-blue);
}
table.items td.link a:focus-visible {
text-decoration-color: var(--color-gray);
}
table.items td.link a:hover:focus-visible {
color: var(--color-dark-blue);
}
table.items td:not(:first-child),
.items-table td:not(:first-child),
.grayed {
color: var(--color-table-gray);
fill: var(--color-table-gray);
fill: var(--color-gray);
}
table.items td.hint {
color: var(--color-table-gray);
font-style: italic;
font-size: 0.8rem;
padding: 1em;
}
table.items svg {
height: 1.2rem;
vertical-align: middle;
width: 1.2rem;
}
table.items td.svg {
.items-table td:has(> svg:only-child) {
text-align: center;
}
table.items td.number {
text-align: right;
}
table.items .button,
table.items button,
table.items input[type=submit] {
font-weight: normal;
padding: 0.3em;
}
table.items input:not([type=submit]):not([type=checkbox]),
table.items select,
table.items textarea {
padding-block: 0.375em;
}
/* TODO: find a way (layers?) to style inputs differently while making sure
* hover works properly without using :not(:hover) selectors here. */
table.items .button:not(:hover),
table.items button:not(:hover),
table.items input:not(:hover),
table.items select:not(:hover),
table.items textarea:not(:hover) {
border-color: var(--color-border-gray);
}
table.items .button:not(:hover),
table.items button:not(:hover),
table.items input[type=submit]:not(:hover),
table.items select:not(:hover) {
color: var(--color-table-gray);
}
table.items select:focus-within,
table.items select:focus-visible {
color: black;
}
form a[name=cancel] {
border-color: var(--color-border-gray);
color: var(--color-nav-gray);
fill: var(--color-nav-gray);
}
form table.items {
border: none;
}
form table.items td {
border: none;
text-align: left;
vertical-align: middle;
}
form table.items td:first-child {
color: inherit;
}
.centered {
.center {
margin: 0 auto;
}
.extendedright {
margin-right: auto;
.hexpand {
width: 100%;
}
.hflex {
.flex {
display: flex;
gap: 0.8em;
}
.vflex {
display: flex;
gap: 0.8em;
.flex.reverse {
flex-direction: row-reverse;
}
.flex.vertical {
flex-direction: column;
}
[disabled] {
border-color: var(--color-border-gray) !important;
color: var(--color-border-gray) !important;
cursor: not-allowed;
fill: var(--color-border-gray) !important;
pointer-events: none;
.hint {
color: var(--color-table-gray);
font-style: italic;
font-size: 0.9rem;
text-align: center;
}
.unwrappable {
.hmin50 {
min-width: 50%;
}
.hminnumber {
min-width: 5em;
}
.hmintext {
min-width: 6em;
}
.hstretch {
/* `width` conflicts with <input>/<textarea> size defined by [size]/[cols]
* attributes. It's best to not use these attributes (e.g. set them to 1) when
* setting width through stylesheet. */
/* NOTE: `field-sizing` can be considered after upgrade do FF >=152 */
width: -moz-available;
width: stretch;
}
.italic {
color: var(--color-gray);
font-style: italic;
}
.ralign {
text-align: right;
}
.rextend {
margin-right: auto;
}
details {
align-content: center;
position: relative;
}
summary {
align-items: center;
color: var(--color-gray);
display: flex;
gap: 0.4em;
height: 100%;
white-space: nowrap;
}
summary::before {
background-color: currentColor;
content: "";
height: 1em;
mask-image: url('chevron-down.svg');
mask-size: cover;
width: 1em;
}
summary:has(.button) {
padding-block: 0;
padding-inline-end: 0;
}
summary .button {
border: 1px solid 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: scaleY(-1);
}
summary::marker {
padding-left: 0.25em;
}
/* NOTE: use `details[open]::details-content` once widely available. */
details[open] ul {
background-color: white;
border: 1px solid 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: '';
}
/*
* TODO:
* * disable <label> containing disabled checkbox: `label:has(input[disabled])`,
* * disabled label styling,
* * focused label styling (currently only checkbox has focus),
* * disabled checkbox blue square focus removal.
* */
#measurement_form {
min-width: 66%;
width: max-content;
}
+15 -3
View File
@@ -25,6 +25,18 @@ class ApplicationController < ActionController::Base
# Turbo will reload 2nd time with HTML format and flashes will be lost.
rescue_from *ActionDispatch::ExceptionWrapper.rescue_responses.keys, with: :rescue_turbo
# Required by #respond_with (gem `responders`) used by Devise controllers.
respond_to :html, :turbo_stream
def after_sign_in_path_for(resource)
# TODO: allow setting path per-user or save last path in session and restore
units_path
end
def after_sign_out_path_for(resource)
new_user_session_path
end
protected
def current_user_disguised?
@@ -43,9 +55,9 @@ class ApplicationController < ActionController::Base
private
def render_no_content(record)
helpers.render_errors(record)
render html: nil, layout: true
def render_errors(record)
target = record.new_record? ? [:new, record.parent || record.class] : [:edit, record]
render 'errors', assigns: {record: record, target: target}
end
def rescue_turbo(exception)
+3 -3
View File
@@ -17,7 +17,7 @@ class Default::UnitsController < ApplicationController
def import
@unit.port!(current_user)
flash.now[:notice] = t('.success', unit: @unit)
flash.now.notice = t('.success', unit: @unit)
ensure
run_and_render :index
end
@@ -30,14 +30,14 @@ class Default::UnitsController < ApplicationController
def export
@unit.port!(nil)
flash.now[:notice] = t('.success', unit: @unit)
flash.now.notice = t('.success', unit: @unit)
ensure
run_and_render :index
end
def destroy
@unit.destroy!
flash.now[:notice] = t('.success', unit: @unit)
flash.now.notice = t('.success', unit: @unit)
ensure
run_and_render :index
end
@@ -1,5 +1,7 @@
class MeasurementsController < ApplicationController
def index
@measurements = []
#@measurements = current_user.units.ordered.includes(:base, :subunits)
end
def new
+14 -10
View File
@@ -19,9 +19,9 @@ class QuantitiesController < ApplicationController
def create
@quantity = current_user.quantities.new(quantity_params)
if @quantity.save
@before = @quantity.successive
@before = @quantity.succeeding
@ancestors = @quantity.ancestors
flash.now[:notice] = t('.success', quantity: @quantity)
flash.now.notice = t('.success', quantity: @quantity)
else
render :new
end
@@ -33,7 +33,7 @@ class QuantitiesController < ApplicationController
def update
if @quantity.update(quantity_params.except(:parent_id))
@ancestors = @quantity.ancestors
flash.now[:notice] = t('.success', quantity: @quantity)
flash.now.notice = t('.success', quantity: @quantity)
else
render :edit
end
@@ -43,18 +43,22 @@ class QuantitiesController < ApplicationController
permitted = params.require(:quantity).permit(:parent_id)
@previous_ancestors = @quantity.ancestors
# Until UI blocks all disallowed reparents, render error messages if present
render_no_content(@quantity) unless @quantity.update(permitted)
@ancestors = @quantity.ancestors
@self_and_progenies = @quantity.with_progenies
@before = @self_and_progenies.last.successive
if @quantity.update(permitted)
@ancestors = @quantity.ancestors
# TODO: simplify into 1 query (see #succeeding comment)
@quantities = @quantity.with_progenies
@before = @quantities.last.succeeding
else
# Until UI blocks all disallowed reparents, render error messages if present.
flash.now.alert = @quantity.errors.full_messages
render :nothing
end
end
def destroy
@quantity.destroy!
@ancestors = @quantity.ancestors
flash.now[:notice] = t('.success', quantity: @quantity)
flash.now.notice = t('.success', quantity: @quantity)
end
private
+13 -17
View File
@@ -1,37 +1,33 @@
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]
def new
new_quantities =
case params[:scope]
when 'children'
@quantity.subquantities
when 'subtree'
@quantity.progenies
else
[@quantity]
end
new_quantities -= @prev_quantities
@readouts = current_user.readouts.build(new_quantities.map { |q| {quantity: q} })
@quantities -= @prev_quantities
# TODO: raise ParameterInvalid if new_quantities.empty?
@readouts = @quantities.map { |q| q.readouts.build }
@user_quantities = current_user.quantities.ordered
@user_units = current_user.units.ordered
@quantities = @prev_quantities + new_quantities
# @common_ancestor = current_user.quantities
# .common_ancestors(all_quantities.map(&:parent_id)).first
quantities = @prev_quantities + @quantities
@superquantity = current_user.quantities
.common_ancestors(quantities.map(&:parent_id)).first
end
def discard
@prev_quantities -= [@quantity]
@common_ancestor = current_user.quantities
@superquantity = current_user.quantities
.common_ancestors(@prev_quantities.map(&:parent_id)).first
end
private
def find_quantities
@quantities = current_user.quantities.find(params[:quantity])
end
def find_quantity
@quantity = current_user.quantities.find_by!(id: params[:id])
end
+17 -26
View File
@@ -1,10 +1,9 @@
class UnitsController < ApplicationController
before_action only: :new do
find_unit if params[:id].present?
end
before_action :find_unit, only: [:edit, :update, :rebase, :destroy]
before_action ->{ @unit = current_user.units.find_by!(id: params[:id]) },
only: [:new, :edit, :update, :rebase, :destroy],
unless: ->{ action_name == "new" && params[:id].nil? }
before_action except: :index do
before_action except: [:index] do
raise AccessForbidden unless current_user.at_least(:active)
end
@@ -17,12 +16,12 @@ class UnitsController < ApplicationController
end
def create
@unit = current_user.units.new(unit_params)
@unit = current_user.units.new(params.expect(Unit::ATTRIBUTES))
if @unit.save
@before = @unit.successive
flash.now[:notice] = t('.success', unit: @unit)
@before = @unit.succeeding
flash.now.notice = t('.success', unit: @unit)
else
render :new
render_errors @unit
end
end
@@ -30,40 +29,32 @@ class UnitsController < ApplicationController
end
def update
if @unit.update(unit_params.except(:base_id))
flash.now[:notice] = t('.success', unit: @unit)
if @unit.update(params.except(:base_id).expect(Unit::ATTRIBUTES))
flash.now.notice = t('.success', unit: @unit)
else
render :edit
render_errors @unit
end
end
# TODO: Avoid double table width change by first un-hiding table header,
# then displaying index, e.g. by re-displaying header in index
def rebase
permitted = params.require(:unit).permit(:base_id)
permitted.merge!(multiplier: 1) if permitted[:base_id].blank? && @unit.multiplier != 1
unit_params = params.expect(unit: :base_id)
unit_params.merge!(multiplier: 1.0) if unit_params[:base_id].blank?
@previous_base = @unit.base
@unit.update!(permitted)
@unit.update!(unit_params)
@before = @unit.successive
@before = @unit.succeeding
if @unit.multiplier_previously_changed?
flash.now[:notice] = t(".multiplier_reset", unit: @unit)
flash.now.notice = t(".multiplier_reset", unit: @unit)
end
end
def destroy
@unit.destroy!
flash.now[:notice] = t('.success', unit: @unit)
flash.now.notice = t('.success', unit: @unit)
end
private
def unit_params
params.require(:unit).permit(Unit::ATTRIBUTES)
end
def find_unit
@unit = current_user.units.find_by!(id: params[:id])
end
end
@@ -1,5 +1,8 @@
class RegistrationsController < Devise::RegistrationsController
before_action :authenticate_user!, only: [:edit, :update, :destroy]
class User::ProfilesController < Devise::RegistrationsController
def destroy
# TODO: Disallow/disable deletion for last admin account; update :edit view
super
end
protected
+1 -1
View File
@@ -37,7 +37,7 @@ class UsersController < ApplicationController
end
# NOTE: limited actions availabe to :admin by design. Users are meant to
# manage their accounts by themselves through registrations. :admin
# manage their accounts by themselves through profiles. :admin
# is allowed to sign-in (disguise) as user and make changes from there.
protected
+108 -76
View File
@@ -1,59 +1,85 @@
module ApplicationHelper
# TODO: replace legacy content_tag with tag.tagname
class LabelledFormBuilder < ActionView::Helpers::FormBuilder
(field_helpers - [:label]).each do |selector|
class LabeledFormBuilder < ActionView::Helpers::FormBuilder
(field_helpers - [:label, :hidden_field]).each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {})
labelled_row_for(method, options) { super }
labeled_field_for(method, options) { super }
end
RUBY_EVAL
end
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)))
def submit(value = nil, options = {})
value, options = nil, value if value.is_a?(Hash)
options[:class] = @template.class_names('button', options[:class])
super
end
private
def labelled_row_for(method, options)
@template.content_tag :tr do
@template.content_tag(:td, label_for(method, options), class: "unwrappable") +
@template.content_tag(:td, options.delete(:readonly) ? @object.public_send(method) : yield,
@object&.errors[method].present? ?
{class: "error", data: {content: @object&.errors.delete(method).join(" and ")}} :
{})
end
def labeled_field_for(method, options)
field = if options.delete(:readonly) then
value = object.public_send(method)
value = @template.l(value) if value.respond_to?(:strftime)
value ||= options[:placeholder]
else
yield
end
label_for(method, options) + field
end
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],
error: @object&.errors[method].present?)
label = label(method, "#{text}:", class: classes)
hint = options.delete(:hint)
error: object.errors[method].present?)
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.with(**handler) { deep_translate(method, **options) }
translation.presence || "#{builder.translation}:"
end
end
def interpolation_missing(key, values, string)
@template.instance_values[key.to_s] || deep_translate(key, **values)
end
# Extension to label text translation:
# * recursive translation,
# * interpolation of (_relative_) translation key names and instance variables,
# * pluralization based on any interpolable value
# TODO: add unit tests for the above
def deep_translate(key, **options)
options[:default] = [
:".#{key}",
:"helpers.label.#{@object_name}.#{key}_html",
:"helpers.label.#{@object_name}.#{key}",
""
]
# At least 1 interpolation key is required for #translate to act on
# missing interpolation arguments (i.e. call custom handler).
# Also setting `key` to nil avoids recurrent translation.
options[key] = nil
@template.t(".#{key}_html", **options) do |translation, resolved_key|
if translation.is_a?(Array) && (count = translation.to_h[:count])
@template.t(resolved_key, count: I18n.interpolate(count, **options), **options)
else
translation
end
end
end
end
def labelled_form_for(record, options = {}, &block)
options = options.deep_merge(builder: LabelledFormBuilder, data: {turbo: false})
form_for(record, **options) { |f| f.form_for(&block) }
def labeled_form_for(record, options = {}, &block)
extra_options = {builder: LabeledFormBuilder, html: {class: 'labeled-form'}}
form_for(record, **merge_attributes(options, extra_options), &block)
end
class TabularFormBuilder < ActionView::Helpers::FormBuilder
@@ -65,32 +91,36 @@ module ApplicationHelper
[:text_field, :password_field, :text_area].each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {})
options[:maxlength] ||= object.class.type_for_attribute(method).limit
if object.errors.include?(method)
options[:pattern] = except_pattern(object.public_send(method), options[:pattern])
end
options[:maxlength] ||= object.type_for_attribute(method).limit
# If [cols]/[size] is not set, minimum field width is controlled with
# stylesheet. To prevent browser estimating missing [cols]/[size]
# based on e.g. [maxlength], set it to 1.
options[:#{selector == :text_area ? :cols : :size}] ||= 1
super
end
RUBY_EVAL
end
def number_field(method, options = {})
value = object.public_send(method)
if value.is_a?(BigDecimal)
options[:value] = value.to_scientific
type = object.class.type_for_attribute(method)
options[:step] ||= BigDecimal(10).power(-type.scale)
options[:max] ||= BigDecimal(10).power(type.precision - type.scale) -
options[:step]
options[:min] = options[:min] == :step ? options[:step] : options[:min]
options[:min] ||= -options[:max]
case object.type_for_attribute(method).type
when :float, :double
options[:min] ||= Float::MIN_15.to_scientific
options[:max] ||= Float::MAX_15.to_scientific
options[:oninput] = 'numberValidate(event)'
options[:size] ||= options[:class]&.split&.include?('hstretch') ? 1 : 6
options[:step] ||= :any
options[:value] = object.public_send("#{method}_before_type_cast")
end
super
end
def button(value = nil, options = {}, &block)
# button does not use #objectify_options
options.merge!(@options.slice(:form))
# #button does not use #objectify_options/@default_options
value, options = nil, value if value.is_a?(Hash)
options = options.merge(
@default_options.slice(:form),
class: @template.class_names('button', options[:class])
)
super
end
@@ -98,11 +128,7 @@ module ApplicationHelper
def submit_default_value
svg_name = object ? (object.persisted? ? 'update' : 'plus-circle-outline') : ''
@template.svg_tag("pictograms/#{svg_name}", super)
end
def except_pattern(value, pattern = nil)
"(?!^#{Regexp.escape(value)}$)" + (pattern || ".*")
@template.svg_tag(svg_name, super)
end
end
@@ -111,20 +137,21 @@ module ApplicationHelper
# [autofocus]. Otherwise IDs are not unique when multiple forms are open
# and the first input gets focus.
record_object, options = nil, record_object if record_object.is_a?(Hash)
options.merge!(builder: TabularFormBuilder, skip_default_ids: true)
render_errors(record_object || record_name)
extra_options = {builder: TabularFormBuilder, skip_default_ids: true}
options = merge_attributes(options, extra_options)
fields_for(record_name, record_object, **options, &block)
end
def tabular_form_with(**options, &block)
options = options.deep_merge(builder: TabularFormBuilder,
html: {autocomplete: 'off'})
form_with(**options, &block)
extra_options = {builder: TabularFormBuilder, class: 'tabular-form',
html: {autocomplete: 'off'}}
form_with(**merge_attributes(options, extra_options), &block)
end
def svg_tag(source, label = nil, options = {})
svg_tag = content_tag :svg, options do
tag.use(href: "#{image_path(source + ".svg")}#icon")
label, options = nil, label if label.is_a? Hash
svg_tag = tag.svg(**options) do
tag.use(href: image_path("#{source}.svg") + '#icon')
end
label.blank? ? svg_tag : svg_tag + tag.span(label)
end
@@ -134,12 +161,13 @@ module ApplicationHelper
['measurements', 'scale-bathroom', :restricted],
['quantities', 'axis-arrow', :restricted, 'right'],
['units', 'weight-gram', :restricted],
# TODO: display users tab only if >1 user present; sole_user?/sole_admin?
['users', 'account-multiple-outline', :admin],
]
menu_tabs.map do |name, image, status, css_class|
if current_user.at_least(status)
link_to svg_tag("pictograms/#{image}", t("#{name}.navigation")),
link_to svg_tag(image, t("#{name}.navigation")),
{controller: "/#{name}", action: "index"},
class: class_names('tab', css_class, active: name == current_tab)
end
@@ -171,21 +199,19 @@ module ApplicationHelper
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)
html_options = html_options.deep_merge DISABLED_ATTRIBUTES if current_page?(options)
if current_page?(options, method: [:get, :post])
html_options = html_options.deep_merge DISABLED_ATTRIBUTES
end
link_to name, options, html_options
end
def render_errors(records)
flash[:alert] ||= []
Array(records).each { |record| flash[:alert] += record.errors.full_messages }
end
def render_flash_messages
def render_flash
flash.map do |entry, messages|
# Conversion of flash to Array only required because of Devise.
Array(messages).map do |message|
tag.div class: "flash #{entry}" do
tag.div(sanitize(message)) + tag.button(sanitize("&times;"), tabindex: -1,
onclick: "this.parentElement.remove();")
tag.span(sanitize(message)) +
svg_tag('close-outline', {onclick: "this.parentElement.remove()"})
end
end
end.join.html_safe
@@ -204,8 +230,6 @@ module ApplicationHelper
private
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],
'button',
@@ -216,10 +240,18 @@ module ApplicationHelper
html_options[:onclick] = "return confirm('\#{html_options[:onclick][:confirm]}');"
end
if type == :link && !(html_options[:onclick] || html_options.dig(:data, :turbo_stream))
name += '...'
end
link_is_local = html_options[:onclick] || html_options.dig(:data, :turbo_stream)
name = name.to_s
name += '...' if type == :link && !link_is_local
name = svg_tag(image, name) if image
[name, html_options]
end
# Like Hash#deep_merge, but aware of HTML attributes.
def merge_attributes(left, right)
left.deep_merge(right) do |key, lvalue, rvalue|
key == :class ? class_names(lvalue, rvalue) : rvalue
end
end
end
+8 -4
View File
@@ -1,7 +1,11 @@
module QuantitiesHelper
def quantity_options(quantities, selected: nil)
values = quantities.map { |q| [sanitize('&emsp;' * q.depth + q.name), q.id] }
values.unshift([t('.select_quantity'), nil, {hidden: true}])
options_for_select(values, selected: selected)
def quantities_check_boxes(quantities)
# Closing <details> on focusout event depends on relatedTarget for internal
# actions being non-null. To ensure this, all top-layer elements of
# ::details-content must accept focus, e.g. <label> needs tabindex="-1" */
collection_check_boxes(nil, :quantity, quantities, :id, :to_s_with_depth,
include_hidden: false) do |b|
content_tag :li, b.label(tabindex: -1) { b.check_box + b.text }
end
end
end
+146 -88
View File
@@ -1,130 +1,188 @@
// Configure your import map in config/importmap.rb. Read more:
// https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "./turbo_actions.js"
// TODO: access `data-` attributes through
// `keyname in`/`dataset.keyname`/`delete dataset.keyname`
/* Hide page before loaded for testing purposes */
// Show page if hidden for testing purposes.
function showPage(event) {
document.documentElement.style.visibility="visible"
}
document.addEventListener('turbo:load', showPage)
/* Turbo stream actions */
Turbo.StreamElement.prototype.enableElement = function(element) {
element.removeAttribute("disabled")
element.removeAttribute("aria-disabled")
// 'tabindex' is not used explicitly, so removing it is safe
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() {
this.targetElements.forEach((e) => { this.enableElement(e) })
}
Turbo.StreamActions.hide = function() {
this.targetElements.forEach((e) => { e.style.display = "none" })
}
Turbo.StreamActions.close_form = function() {
this.targetElements.forEach((e) => {
/* Move focus if there's no focus or focus inside form being closed */
const focused = document.activeElement
if (!focused || (focused == document.body) || e.contains(focused)) {
let nextForm = e.parentElement.querySelector(`#${e.id} ~ tr:has([autofocus])`)
nextForm ??= e.parentElement.querySelector("tr:has([autofocus])")
nextForm?.querySelector("[autofocus]").focus()
}
document.getElementById(e.getAttribute("data-form")).remove()
if (e.hasAttribute("data-link")) {
this.enableElement(document.getElementById(e.getAttribute("data-link")))
}
if (e.hasAttribute("data-hidden-row")) {
document.getElementById(e.getAttribute("data-hidden-row")).removeAttribute("style")
}
e.remove()
})
}
/* Items table drag and drop support */
function processKey(event) {
if (event.key == "Escape") {
event.currentTarget.querySelector("a[name=cancel]").click();
// <details> event handlers.
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.processKey = processKey;
window.detailsChange = detailsChange
var lastEnterTime;
function dragStart(event) {
lastEnterTime = event.timeStamp;
var row = event.currentTarget;
row.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden");
});
event.dataTransfer.setData("text/plain", row.getAttribute("data-drag-path"));
var rowRectangle = row.getBoundingClientRect();
event.dataTransfer.setDragImage(row, event.x - rowRectangle.left, event.y - rowRectangle.top);
event.dataTransfer.dropEffect = "move";
// Close open <details> when focus lost.
function detailsClose(event) {
if (!event.relatedTarget ||
event.relatedTarget.closest("details") != event.currentTarget) {
event.currentTarget.removeAttribute("open")
}
}
window.dragStart = dragStart;
window.detailsClose = detailsClose
window.detailsObserver = new MutationObserver((mutations) => {
mutations[0].target.dispatchEvent(new Event('change', {bubbles: true}))
});
// Validation of multiple-submit forms with different field requirements.
function formValidate(event) {
var id = event.submitter.getAttribute("data-validate")
if (!id) return;
var input = document.getElementById(id)
if (!input.checkValidity()) {
input.reportValidity()
event.preventDefault()
}
}
window.formValidate = formValidate
// Pattern for number precision verification. Should accept all number formats
// accepted by Kernel#Float. Lookahead checks the number of significant digits
// in mantissa, following expression makes sure there is only one dot.
const numberRegExp = /^\-?(?=(?:\.?0)*(?:\.?\d){1,15}?(?:\.?0)*\.?(?:[eE]|$))(?<mantissa>\d*\.?\d+|\d+\.)(?:[eE]\-?\d{1,3})?$/
// Validation of input[type=number] value precision.
function numberValidate(event) {
var input = event.target
input.setCustomValidity("")
if (!input.validity.valid) return
var match = numberRegExp.exec(input.value)
if (!match ||
(parseFloat(input.value) == 0.0 && match.groups['mantissa'].match(/[1-9]/))) {
// TODO: move message to locales
input.setCustomValidity("Please select a value within range of double precision floating-point format.")
}
}
window.numberValidate = numberValidate
// Keyboard event processing handlers.
function formProcessKey(event) {
switch (event.key) {
case "Escape":
event.currentTarget.querySelector("a[name=cancel]").click()
break
// TODO: allow new line inside <textarea> (submit with ctrl-enter?)
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.
var lastEnterTime
function dragStart(event) {
lastEnterTime = event.timeStamp
var row = event.currentTarget
row.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden")
})
event.dataTransfer.setData("text/plain", row.getAttribute("data-drag-path"))
var rowRectangle = row.getBoundingClientRect()
event.dataTransfer.setDragImage(row, event.x - rowRectangle.left,
event.y - rowRectangle.top)
event.dataTransfer.dropEffect = "move"
}
window.dragStart = dragStart
/*
* Drag tracking assumptions (based on FF 122.0 experience):
* * Enter/Leave events at the same timeStamp may not be logically ordered
* (e.g. E -> E -> L, not E -> L -> E),
* * not every Enter event has corresponding Leave event, especially during
* rapid pointer moves
* rapid pointer moves.
* NOTE: sometimes Leave is not emitted when pointer goes fast over table
* and outside. This should probably be fixed in browser, than patched here.
* TODO: replace Leave table with Enter document?
* https://www.quirksmode.org/js/events_order.html#link4
*/
function dragEnter(event) {
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id);
dragLeave(event);
lastEnterTime = event.timeStamp;
const id = event.currentTarget.getAttribute("data-drop-id");
document.getElementById(id).classList.add("dropzone");
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id)
dragLeave(event)
lastEnterTime = event.timeStamp
const id = event.currentTarget.getAttribute("data-drop-id")
document.getElementById(id).classList.add("dropzone")
}
window.dragEnter = dragEnter;
window.dragEnter = dragEnter
function dragOver(event) {
event.preventDefault();
event.preventDefault()
}
window.dragOver = dragOver;
window.dragOver = dragOver
function dragLeave(event) {
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id);
// Leave has been accounted for by Enter at the same timestamp, processed earlier
if (event.timeStamp <= lastEnterTime) return;
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id)
// Leave has been accounted for by Enter at the same timestamp, processed earlier.
if (event.timeStamp <= lastEnterTime) return
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) {
dragLeave(event);
dragLeave(event)
event.currentTarget.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden");
});
tr.toggleAttribute("hidden")
})
}
window.dragEnd = dragEnd;
window.dragEnd = dragEnd
function drop(event) {
event.preventDefault();
event.preventDefault()
var params = new URLSearchParams();
var id_param = event.currentTarget.getAttribute("data-drop-id-param");
var id = event.currentTarget.getAttribute("data-drop-id").split("_").pop();
params.append(id_param, id);
var params = new URLSearchParams()
var id_param = event.currentTarget.getAttribute("data-drop-id-param")
var id = event.currentTarget.getAttribute("data-drop-id").split("_").pop()
params.append(id_param, id)
fetch(event.dataTransfer.getData("text/plain"), {
body: params,
@@ -138,4 +196,4 @@ function drop(event) {
.then(response => response.text())
.then(html => Turbo.renderStreamMessage(html))
}
window.drop = drop;
window.drop = drop
+97
View File
@@ -0,0 +1,97 @@
// Turbo Streams actions.
const FORM_CONTROLS = ["BUTTON", "FIELDSET", "INPUT", "OPTGROUP", "OPTION", "SELECT",
"TEXTAREA"]
Object.assign(Turbo.StreamElement.prototype, {
disableElement(element) {
if (FORM_CONTROLS.includes(element.tagName)) {
element.setAttribute("disabled", "disabled")
} else {
element.setAttribute("aria-disabled", "true")
// NOTE: tabindex should not be disabled?
element.setAttribute("tabindex", "-1")
}
},
enableElement(element) {
if (FORM_CONTROLS.includes(element.tagName)) {
element.removeAttribute("disabled")
} else {
element.removeAttribute("aria-disabled")
// Assume 'tabindex' is not used explicitly, so removing it is safe.
element.removeAttribute("tabindex")
}
},
showElement(element) { element.style.visibility = "visible" }
})
Object.assign(Turbo.StreamActions, {
disable() {
this.targetElements.forEach((e) => { this.disableElement(e) })
},
enable() {
this.targetElements.forEach((e) => { this.enableElement(e) })
},
hide() {
this.targetElements.forEach((e) => { e.style.visibility = "collapse" })
},
show() {
this.targetElements.forEach((e) => { this.showElement(e) })
},
unselect() {
this.targetElements.forEach((e) => { e.checked = false; this.enableElement(e) })
},
validate_form() {
this.targetElements.forEach((form) => {
this.templateContent.querySelectorAll("span").forEach((span) => {
var control = document.querySelector(
`[form="${form.id}"][name="${span.dataset.name}"]`
)
control.setCustomValidity(span.textContent)
// Add event listener in capture phase to clear custom validity before
// `[oninput]` or any other bubble phase handler is invoked.
// https://www.quirksmode.org/js/events_order.html
// TODO: keep invalidating current value whenever user re-enters it. Store
// invalid value and validation message in `[data-invalid-message/value]`
// and check input value on "input" event (below) (using `[pattern]` may not
// be suitable due to inability to customize validtion message; also
// pattern may be used for other, conflicting purposes).
// After user executes any other action (while form is open), remove `[data-]`
// attributes and custom validation, as value may no longer be invalid.
control.addEventListener(
"input",
function(event) { event.target.setCustomValidity("") },
{capture: true, once: true}
)
})
form.reportValidity()
})
},
close_form() {
this.targetElements.forEach((e) => {
// Move focus if there's no focus or focus inside form being closed.
const focused = document.activeElement
if (!focused || (focused == document.body) || e.contains(focused)) {
let nextForm = e.parentElement.querySelector(`#${e.id} ~ tr:has([autofocus])`)
nextForm ??= e.parentElement.querySelector("tr:has([autofocus])")
nextForm?.querySelector("[autofocus]").focus()
}
document.getElementById(e.dataset.form).remove()
if ('link' in e.dataset) {
this.enableElement(document.getElementById(e.dataset.link))
}
if ('hiddenRow' in e.dataset) {
this.showElement(document.getElementById(e.dataset.hiddenRow))
}
e.remove()
})
}
})
+2
View File
@@ -1,4 +1,6 @@
class ApplicationRecord < ActiveRecord::Base
include ActionView::Helpers::TagHelper
class << self
# Cached attribute has non-user assignable value calculated from other
# attributes' values on create/update. This simplifies and speeds up
+3
View File
@@ -0,0 +1,3 @@
class Note < ApplicationRecord
ATTRIBUTES = [:text]
end
+51 -23
View File
@@ -6,16 +6,21 @@ class Quantity < ApplicationRecord
belongs_to :parent, optional: true, class_name: "Quantity"
has_many :subquantities, ->{ order(:name) }, class_name: "Quantity",
inverse_of: :parent, dependent: :restrict_with_error
has_many :readouts, dependent: :restrict_with_error
validate if: ->{ parent.present? } do
errors.add(:parent, :user_mismatch) unless user_id == parent.user_id
errors.add(:parent, :self_reference) if id == parent_id
end
validate if: ->{ parent.present? }, on: :update do
# TODO: should not report error for (parent == self)
errors.add(:parent, :descendant_reference) if ancestor_of?(parent)
end
validates :name, presence: true, uniqueness: {scope: [:user_id, :parent_id]},
length: {maximum: type_for_attribute(:name).limit}
# FIXME: length validation for :text type column is probably wrong; reported
# limit is in bytes (at least for MySQL) and check is for #length (i.e.
# characters); this doesn't seem to apply to :string (implemented as varchar)
validates :description, length: {maximum: type_for_attribute(:description).limit}
# Update :depths of progenies after parent change
@@ -60,26 +65,42 @@ class Quantity < ApplicationRecord
scope :defaults, ->{ where(user: nil) }
# Return: ordered [sub]hierarchy
scope :ordered, ->(root: nil, include_root: true) {
numbered = Arel::Table.new('numbered')
scope :ordered, ->(**kwargs) {
with_path(**kwargs).order(arel_table[:path])
}
self.model.with(numbered: numbered(:parent_id, :name)).with_recursive(arel_table.name => [
numbered.project(
numbered[Arel.star],
numbered.cast(numbered[:child_number], 'BINARY').as('path')
).where(numbered[root && include_root ? :id : :parent_id].eq(root)),
numbered.project(
numbered[Arel.star],
arel_table[:path].concat(numbered[:child_number])
).join(arel_table).on(numbered[:parent_id].eq(arel_table[:id]))
]).order(arel_table[:path])
# TODO: sort recursive CTE using ORDER BY; it's supported by SQLite, so
# drop MySQL until it is available (?)
# Only ORDER BY allows proper i.e. not placing limits on user data and
# efficient with regard to paging (LIMIT/OFFSET) depth-first search.
# Sorting based on numbering is less elegant/efficient (MySQL: ROW_NUMBER cannot
# be used inside recursive CTE and needs separate SELECT,
# https://bugs.mysql.com/bug.php?id=96538).
# Sorting based on pathname requires I) padding to maximum column length or
# II) delimiter which a) cannot be used in sorted column and b) sorts before
# anything else (this adds complexity; NULL cannot be used when sorting).
# Sorting based on ARRAY is only available on PostgreSQL.
scope :with_path, ->(root: nil, include_root: true) {
with_number = Arel::Table.new('with_number')
self.model.with(with_number: with_number(:parent_id, :name))
.with_recursive(arel_table.name => [
with_number.project(
with_number[Arel.star],
with_number.cast(with_number[:number], 'BINARY').as('path')
).where(with_number[root && include_root ? :id : :parent_id].eq(root)),
with_number.project(
with_number[Arel.star],
arel_table[:path].concat(with_number[:number])
).join(arel_table).on(with_number[:parent_id].eq(arel_table[:id]))
])
}
# TODO: extract named functions to custom Arel extension
# NOTE: once recursive queries allow use of window functions, this scope can
# be merged with :ordered
# https://gist.github.com/ProGM/c6df08da14708dcc28b5ca325df37ceb#extending-arel
scope :numbered, ->(parent_column, order_column) {
scope :with_number, ->(parent_column, order_column) {
select(
arel_table[Arel.star],
Arel::Nodes::NamedFunction.new(
@@ -92,12 +113,17 @@ class Quantity < ApplicationRecord
).from(arel_table),
Arel::Nodes.build_quoted('0')
],
).as('child_number')
).as('number')
)
}
def to_s
name
tag.pre name
end
def to_s_with_depth
# em space, U+2003
' ' * depth + name
end
def destroyable?
@@ -125,15 +151,17 @@ class Quantity < ApplicationRecord
.order(arel_table[:depth].desc)
}
# Return: successive record in order of appearance; used for partial view reload
def successive
# Return: succeeding record in order of appearance, used for partial view reload.
# TODO: add scope returning succeeding element for multiple records
def succeeding
quantities = Quantity.arel_table
Quantity.with(
quantities: user.quantities.ordered.select(
quantities[Arel.star],
Arel::Nodes::NamedFunction.new('LAG', [quantities[:id]]).over.as('lag_id')
)
).where(quantities[:lag_id].eq(id)).first
Quantity.with(lagging_quantities: user.quantities.with_path.select(
quantities[Arel.star],
# For SQLite 'OVER () ORDER BY x' is not equivalent to 'OVER (ORDER BY x)'.
Arel::Nodes::NamedFunction.new('LAG', [quantities[:id]])
.over(Arel::Nodes::Window.new.order(Quantity.ordered.order_values)).as('lag_id')
)).from(Arel::Table.new(:lagging_quantities).as(:quantities))
.where(quantities[:lag_id].eq(id)).take
end
def with_progenies
+2
View File
@@ -4,4 +4,6 @@ class Readout < ApplicationRecord
belongs_to :user
belongs_to :quantity
belongs_to :unit
# TODO: validate quantity.user_id == unit.user_id != NULL
end
+23 -22
View File
@@ -1,9 +1,12 @@
class Unit < ApplicationRecord
ATTRIBUTES = [:symbol, :description, :multiplier, :base_id]
ATTRIBUTES = {unit: [:symbol, :description, :multiplier, :base_id]}
belongs_to :user, optional: true
belongs_to :base, optional: true, class_name: "Unit"
has_many :subunits, class_name: "Unit", inverse_of: :base, dependent: :restrict_with_error
# TODO: use #alias_attribute after Rails upgrade?
alias_method :parent, :base
has_many :subunits, class_name: "Unit", inverse_of: :base,
dependent: :restrict_with_error
validate if: ->{ base.present? } do
errors.add(:base, :user_mismatch) unless user_id == base.user_id
@@ -13,8 +16,8 @@ class Unit < ApplicationRecord
validates :symbol, presence: true, uniqueness: {scope: :user_id},
length: {maximum: type_for_attribute(:symbol).limit}
validates :description, length: {maximum: type_for_attribute(:description).limit}
validates :multiplier, numericality: {equal_to: 1}, unless: :base
validates :multiplier, numericality: {greater_than: 0, precision: true, scale: true}, if: :base
validates :multiplier, numericality: {equal_to: 1.0}, unless: :base
validates :multiplier, numericality: {greater_than: 0.0}, if: :base
scope :defaults, ->{ where(user: nil) }
scope :defaults_diff, ->{
@@ -76,23 +79,21 @@ class Unit < ApplicationRecord
.from(units).group(:base_id, :symbol)
}
scope :ordered, ->{
left_outer_joins(:base).order(ordering)
left_outer_joins(:base).order([
arel_table.coalesce(Arel::Table.new(:bases_units)[:symbol], arel_table[:symbol]),
arel_table[:base_id].not_eq(nil),
arel_table[:multiplier],
arel_table[:symbol]
])
}
def self.ordering
[arel_table.coalesce(Arel::Table.new(:bases_units)[:symbol], arel_table[:symbol]),
arel_table[:base_id].not_eq(nil),
:multiplier,
:symbol]
end
before_destroy do
# TODO: disallow destruction if any object depends on this unit
nil
end
def to_s
symbol
tag.pre symbol
end
def movable?
@@ -103,21 +104,21 @@ class Unit < ApplicationRecord
user_id.nil?
end
# Should only by invoked on Units returned from #defaults_diff which are #portable
# Should only by invoked on Units returned from #defaults_diff which are #portable.
def port!(recipient)
recipient_base = base && Unit.find_by!(symbol: base.symbol, user: recipient)
params = slice(ATTRIBUTES - [:symbol, :base_id])
params = slice(ATTRIBUTES[:unit] - [:symbol, :base_id])
Unit.find_or_initialize_by(user: recipient, symbol: symbol)
.update!(base: recipient_base, **params)
end
def successive
def succeeding
units = Unit.arel_table
lead = Arel::Nodes::NamedFunction.new('LAG', [units[:id]])
window = Arel::Nodes::Window.new.order(*Unit.ordering)
lag_id = lead.over(window).as('lag_id')
Unit.with(
units: user.units.left_outer_joins(:base).select(units[Arel.star], lag_id)
).where(units[:lag_id].eq(id)).first
Unit.with(lagging_units: user.units.left_outer_joins(:base).select(
units[Arel.star],
Arel::Nodes::NamedFunction.new('LAG', [units[:id]])
.over(Arel::Nodes::Window.new.order(Unit.ordered.order_values)).as('lag_id')
)).from(Arel::Table.new(:lagging_units).as(:units))
.where(units[:lag_id].eq(id)).take
end
end
+3 -3
View File
@@ -12,10 +12,10 @@ class User < ApplicationRecord
disabled: 0, # administratively disallowed to sign in
}, default: :active, validate: true
has_many :readouts, dependent: :destroy
has_many :readouts, dependent: :delete_all
accepts_nested_attributes_for :readouts
has_many :quantities, dependent: :destroy
has_many :units, dependent: :destroy
has_many :quantities, dependent: :delete_all
has_many :units, dependent: :delete_all
validates :email, presence: true, uniqueness: true,
length: {maximum: type_for_attribute(:email).limit}
@@ -0,0 +1,7 @@
<%= turbo_stream.validate_form dom_target(*@target, :form) do %>
<% @record.errors.attribute_names.map do |attr| %>
<%= tag.span data: {name: field_name(dom_class(@record), attr)} do %>
<%- @record.errors.full_messages_for(attr).map { |m| m + '.' }.join(' ') %>
<% end %>
<% end %>
<% end %>
+1 -1
View File
@@ -5,7 +5,7 @@
</td>
<% if current_user.at_least(:active) %>
<td class="actions">
<td class="flex">
<% unless unit.portable.nil? %>
<% if unit.default? %>
<%= image_button_to_if unit.portable?, t('.import'), 'download-outline',
+5 -4
View File
@@ -1,16 +1,17 @@
<div class="rightside buttongrid">
<div class="rightside-area buttongrid">
<% if current_user.at_least(:active) %>
<%# TODO: implement Import all %>
<%#= image_button_to t('.import_all'), 'download-multiple-outline',
import_all_default_units_path, data: {turbo_stream: true} %>
<% end %>
<%= image_link_to t('.back'), 'arrow-left-bold-outline', units_path, class: 'tools' %>
<%= image_link_to t('.back'), 'arrow-left-bold-outline', units_path,
class: 'tools-area' %>
</div>
<table class="main items">
<table class="main-area items-table">
<thead>
<tr>
<th><%= User.human_attribute_name(:symbol).capitalize %></th>
<th><%= Unit.human_attribute_name(:symbol) %></th>
<% if current_user.at_least(:active) %>
<th><%= t '.actions' %></th>
<% end %>
+4 -4
View File
@@ -23,10 +23,10 @@
</head>
<body>
<header class="hflex">
<header class="flex">
<%= image_link_to t(".source_code"), "code-braces", source_code_url %>
<%= image_link_to t(".issue_tracker"), "bug-outline", issue_tracker_url,
class: "extendedright" %>
class: "rextend" %>
<% if user_signed_in? %>
<%= image_link_to_unless_current(current_user, "account-wrench-outline",
edit_user_registration_path) %>
@@ -44,10 +44,10 @@
</header>
<div id="flashes">
<%= render_flash_messages %>
<%= render_flash %>
</div>
<%# Allow overwriting/clearing navigation menu for some views %>
<%# Allows overwriting/clearing navigation menu for some views %>
<nav class="navigation">
<%= content_for(:navigation) || (navigation_menu if user_signed_in?) %>
</nav>
@@ -1,6 +1,6 @@
<%= yield %>
<%# Some views may convert ActiveRecord errors to flashes, render at the end. %>
<%# Render unconditionally, to clear previous flash messages. %>
<%= turbo_stream.update :flashes do %>
<%= render_flash_messages %>
<%= render_flash %>
<% end %>
<%= yield %>
+43 -26
View File
@@ -1,29 +1,46 @@
<%= tabular_form_with model: Measurement.new do |form| %>
<fieldset>
<legend>
<%= tag.span id: :measurement_form_legend %>
<%= image_link_to '', "close-outline", measurements_path, name: :cancel,
class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</legend>
<table class="items">
<tbody id="readouts">
<tr id="readouts_form">
<td>
<%= select_tag :id, quantity_options(@quantities), onchange:
"this.form.requestSubmit(document.getElementById('readout_submit'));",
class: 'quantity' %>
<%= form.submit id: :readout_submit, name: nil, value: nil,
formaction: new_readout_path, formmethod: :get, formnovalidate: true,
hidden: true, data: {turbo_stream: true} %>
<%= tabular_form_with model: Measurement.new, id: :measurement_form,
class: 'topside-area flex vertical center',
html: {onkeydown: 'formProcessKey(event)'} do |form| %>
<table class="items-table center">
<tbody id="readouts">
<%= tabular_fields_for @measurement do |form| %>
<tr class="italic">
<td class="hexpand hmin50"><%= t '.taken_at_html' %></td>
<td colspan="3" class="ralign">
<%= form.datetime_field :taken_at, required: true %>
</td>
<td></td>
<td></td>
<td></td>
</tr>
<tr id="measurement_form_actions">
<td colspan="4"><div class="actions centered"><%= form.button %></div></td>
</tr>
</tbody>
</table>
</fieldset>
<% end %>
</tbody>
</table>
<%# TODO: right-click selection; unnecessary with hierarchical tags? %>
<details id="quantity_select" class="center hexpand" open
onkeydown="detailsProcessKey(event)">
<summary autofocus>
<%# TODO: Set content with CSS when span empty to avoid duplication; or
change to disabled input with prompt %>
<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(@quantities) %></ul>
</details>
<div class="flex reverse">
<%= form.button id: :create_measurement_button, disabled: true -%>
<%= image_link_to t(:cancel), "close-outline", measurements_path, name: :cancel,
class: 'auxiliary dangerous', onclick: render_turbo_stream('form_close') %>
</div>
<% end %>
<script>
quantity_select.addEventListener('focusout', detailsClose)
quantity_select.addEventListener('change', detailsChange)
detailsObserver.observe(quantity_select.querySelector('ul'),
{subtree: true, attributeFilter: ['disabled']})
</script>
+3 -1
View File
@@ -1,2 +1,4 @@
<%= turbo_stream.update :measurement_form %>
<%= turbo_stream.update :flashes %>
<%= turbo_stream.remove :measurement_form %>
<%= turbo_stream.show :no_items -%>
<%= turbo_stream.enable :new_measurement_link -%>
+6 -2
View File
@@ -1,5 +1,5 @@
<%# TODO: show hint when no quantities/units defined %>
<div class="rightside buttongrid">
<div class="rightside-area buttongrid">
<% if current_user.at_least(:active) %>
<%= image_link_to t('.new_measurement'), 'plus-outline', new_measurement_path,
id: :new_measurement_link, onclick: 'this.blur();',
@@ -7,4 +7,8 @@
<% end %>
</div>
<%= tag.div class: 'main', id: :measurement_form %>
<table class="main-area">
<tbody id="measurements">
<%= render(@measurements) || render_no_items %>
</tbody>
</table>
+2 -1
View File
@@ -1,4 +1,5 @@
<%= 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' %>
<% end %>
+8 -11
View File
@@ -1,19 +1,16 @@
<%= 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 %>
<td style="--depth:<%= @quantity.depth %>">
<%= form.text_field :name, required: true, autofocus: true, size: 20 %>
</td>
<td>
<%= form.text_area :description, cols: 30, rows: 1, escape: false %>
</td>
<%= form.text_field :name, autofocus: true, class: 'hstretch', required: true %>
<td><%= form.text_area :description, class: 'hstretch', escape: false, rows: 1 %>
<td class="actions">
<td class="flex">
<%= form.button %>
<%= image_link_to t(:cancel), "close-outline", quantities_path, class: 'dangerous',
name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %>
</td>
<td></td>
<%= image_link_to t(:cancel), "close-outline", quantities_path,
class: 'dangerous', name: :cancel,
onclick: render_turbo_stream('form_close', {row: row}) %>
<td>
<% end %>
<% end %>
+17 -17
View File
@@ -1,24 +1,24 @@
<%= tag.tr id: dom_id(quantity),
ondragstart: "dragStart(event)", ondragend: "dragEnd(event)",
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drag_path: reparent_quantity_path(quantity), drop_id: dom_id(quantity),
drop_id_param: "quantity[parent_id]"} do %>
<tr id="<%= dom_target(quantity) %>"
ondragstart="dragStart(event)" ondragend="dragEnd(event)"
ondragover="dragOver(event)" ondrop="drop(event)"
ondragenter="dragEnter(event)" ondragleave="dragLeave(event)"
data-drag-path="<%= reparent_quantity_path(quantity) %>"
data-drop-id="<%= dom_target(quantity) %>"
data-drop-id-param="<%= field_name(:quantity, :parent_id) %>">
<td class="link" style="--depth:<%= quantity.depth %>">
<%= link_to quantity, edit_quantity_path(quantity), onclick: 'this.blur();',
data: {turbo_stream: true} %>
</td>
<td><%= quantity.description %></td>
<td style="--depth:<%= quantity.depth %>">
<%= link_to quantity, edit_quantity_path(quantity), class: 'link',
onclick: 'this.blur();', data: {turbo_stream: true} %>
<td><%= quantity.description %>
<% if current_user.at_least(:active) %>
<td class="actions">
<%= image_link_to t('.new_subquantity'), 'plus-outline', new_quantity_path(quantity),
id: dom_id(quantity, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>
<td class="flex">
<%= image_link_to t('.new_subquantity'), 'plus-outline',
new_quantity_path(quantity), id: dom_target(:new, quantity, :link),
onclick: 'this.blur();', data: {turbo_stream: true} %>
<%= image_button_to_if quantity.destroyable?, t('.destroy'), 'delete-outline',
quantity_path(quantity), method: :delete %>
</td>
<td class="handle" draggable="true">&#x283F</td>
<td class="handle" draggable="true">&#x283F
<% end %>
<% end %>
+2 -4
View File
@@ -1,7 +1,5 @@
<%= turbo_stream.close_form dom_id(@quantity.parent || Quantity, :new) %>
<%= turbo_stream.remove :no_items %>
<%= turbo_stream.close_form dom_target(:new, @quantity.parent || :quantity) %>
<% @ancestors.each do |ancestor| %>
<%= turbo_stream.replace ancestor %>
<% end %>
<%= @before ? turbo_stream.before(@before, @quantity) :
turbo_stream.append(:quantities, @quantity) %>
<%= turbo_stream.before @before || :endmark, @quantity %>
@@ -2,4 +2,3 @@
<%= turbo_stream.replace ancestor %>
<% end %>
<%= turbo_stream.remove @quantity %>
<%= turbo_stream.append(:quantities, render_no_items) if current_user.quantities.empty? %>
+4 -5
View File
@@ -1,13 +1,12 @@
<% ids = {row: dom_id(@quantity, :edit),
hidden_row: dom_id(@quantity),
<% ids = {row: dom_target(:edit, @quantity),
hidden_row: dom_target(@quantity),
link: nil,
form_tag: dom_id(@quantity, :edit, :form)} %>
form_tag: dom_target(:edit, @quantity, :form)} %>
<%= turbo_stream.append :quantity_form do %>
<%= turbo_stream.append_all 'body' do %>
<%- tabular_form_with model: @quantity, html: {id: ids[:form_tag]} do %>
<% end %>
<% end %>
<%= turbo_stream.hide ids[:hidden_row] %>
<%= turbo_stream.remove ids[:row] %>
<%= turbo_stream.after @quantity, partial: 'form', locals: ids -%>
+22 -24
View File
@@ -1,33 +1,31 @@
<div class="rightside buttongrid">
<div class="rightside-area buttongrid">
<% if current_user.at_least(:active) %>
<%= image_link_to t('.new_quantity'), 'plus-outline', new_quantity_path,
id: dom_id(Quantity, :new, :link), onclick: 'this.blur();',
data: {turbo_stream: true} %>
id: :new_quantity_link, onclick: 'this.blur();', data: {turbo_stream: true} %>
<% end %>
<%#= image_link_to t('.import_quantities'), 'download-outline', default_quantities_path,
class: 'tools' %>
<%#= image_link_to t('.import_quantities'), 'download-outline',
default_quantities_path, class: 'tools-area' %>
</div>
<%= tag.div class: 'main', id: :quantity_form %>
<table class="main-area items-table">
<caption class="hint"><%= t '.no_items' %></caption>
<table class="main items">
<thead>
<tbody>
<tr>
<th><%= Quantity.human_attribute_name(:name).capitalize %></th>
<th><%= Quantity.human_attribute_name(:description).capitalize %></th>
<% if current_user.at_least(:active) %>
<th><%= t :actions %></th>
<th></th>
<% end %>
</tr>
<%= tag.tr id: "quantity_", hidden: true,
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drop_id: "quantity_", drop_id_param: "quantity[parent_id]"} do %>
<th colspan="4"><%= t '.top_level_drop' %></th>
<th class="hmintext"><%= Quantity.human_attribute_name(:name) %>
<th class="hexpand"><%= Quantity.human_attribute_name(:description) %>
<% if current_user.at_least(:active) %>
<th><%= t :actions %>
<th>
<tr id="quantity_" hidden
ondragover="dragOver(event)" ondrop="drop(event)"
ondragenter="dragEnter(event)" ondragleave="dragLeave(event)"
data-drop-id="quantity_"
data-drop-id-param="<%= field_name(:quantity, :parent_id) %>">
<th colspan="4"><%= t '.top_level_drop' %>
<% end %>
</thead>
<tbody id="quantities">
<%= render(@quantities) || render_no_items %>
</tbody>
<%= render @quantities %>
<tr id="endmark" hidden>
</table>
+6 -11
View File
@@ -1,20 +1,15 @@
<% dom_obj = @quantity.parent || @quantity %>
<% ids = {row: dom_id(dom_obj, :new),
<% target_id = dom_target(:new, @quantity.parent || :quantity) %>
<% ids = {row: target_id,
hidden_row: nil,
link: dom_id(dom_obj, :new, :link),
form_tag: dom_id(dom_obj, :new, :form)} %>
link: dom_target(target_id, :link),
form_tag: dom_target(target_id, :form)} %>
<%= turbo_stream.disable ids[:link] -%>
<%= turbo_stream.append :quantity_form do %>
<%= turbo_stream.append_all 'body' do %>
<%- tabular_form_with model: @quantity, html: {id: ids[:form_tag]} do |form| %>
<%= form.hidden_field :parent_id if @quantity.parent_id? %>
<% end %>
<% end %>
<% if @quantity.parent_id? %>
<%= turbo_stream.remove ids[:row] %>
<%= turbo_stream.after @quantity.parent, partial: 'form', locals: ids %>
<% else %>
<%= turbo_stream.prepend :quantities, partial: 'form', locals: ids %>
<% end %>
<%= turbo_stream.after @quantity.parent || :quantity_, partial: 'form', locals: ids %>
@@ -1,9 +1,7 @@
<% @self_and_progenies.each do |q| %>
<%= turbo_stream.remove q %>
<% end %>
<% @previous_ancestors.union(@ancestors).each do |ancestor| %>
<%= turbo_stream.replace ancestor %>
<% end %>
<% @self_and_progenies.each do |q| %>
<%= @before ? turbo_stream.before(@before, q) : turbo_stream.append(:quantities, q) %>
<% @quantities.each do |quantity| %>
<%= turbo_stream.remove quantity %>
<%= turbo_stream.before @before || :endmark, quantity %>
<% end %>
+1 -1
View File
@@ -1,4 +1,4 @@
<%= turbo_stream.close_form dom_id(@quantity, :edit) %>
<%= turbo_stream.close_form dom_target(:edit, @quantity) %>
<% @ancestors.push(@quantity).each do |ancestor| %>
<%= turbo_stream.replace ancestor %>
<% end %>
+11 -17
View File
@@ -1,28 +1,22 @@
<%# TODO: add readout reordering by dragging %>
<%= tabular_fields_for 'readouts[]', readout do |form| %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout),
onkeydown: 'processKey(event)' do %>
<%- tag.tr id: dom_target(:new, readout.quantity, :readout) do %>
<td>
<%#= readout.quantity.relative_pathname(@common_ancestor) %>
<%= form.collection_select :quantity_id, @user_quantities,
:id, ->(q){ sanitize('&emsp;' * q.depth + q.name) },
{disabled: @quantities.map(&:id).delete!(form.object.quantity.id)},
{class: 'quantity'} %>
<%# TODO: add grayed readout index (in separate column?) %>
<%= readout.quantity.relative_pathname(@superquantity) %>
<%= form.hidden_field :quantity_id %>
</td>
<td>
<%= form.number_field :value, required: true, autofocus: true, size: 10 %>
<%= form.number_field :value, required: true, autofocus: readout_counter == 0 %>
</td>
<td>
<%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) } %>
->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) },
{prompt: '', disabled: '', selected: ''}, required: true %>
</td>
<td class="actions">
<%= image_button_tag t('.new_children'), 'plus-multiple-outline',
formaction: new_measurement_path(readout.quantity, :children),
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>
<%#= image_button_tag t('.new_subtree'), 'plus-multiple-outline',
formaction: new_measurement_path(:subtree), **common_options -%>
<%= image_button_tag '', 'delete-outline', class: 'dangerous',
<td class="flex">
<%# 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>
@@ -1,2 +0,0 @@
<%= turbo_stream.remove dom_id(@quantity, :new, :readout) %>
<%= render partial: 'form_repath' %>
+3 -3
View File
@@ -1,7 +1,7 @@
<%= turbo_stream.update(:measurement_form_legend) { @common_ancestor&.pathname } %>
<%= turbo_stream.update(:measurement_form_legend) { @superquantity&.pathname } %>
<% @prev_quantities.each do |pq| %>
<%= turbo_stream.update dom_id(pq, nil, :pathname) do %>
<%= pq.relative_pathname(@common_ancestor) %>
<%= turbo_stream.update dom_target(pq, :pathname) do %>
<%= pq.relative_pathname(@superquantity) %>
<% end %>
<% end %>
@@ -0,0 +1,4 @@
<%= turbo_stream.disable :create_measurement_button if @prev_quantities.one? %>
<%= turbo_stream.remove dom_target(:new, @quantity, :readout) %>
<%= render partial: 'form_repath' %>
<%= turbo_stream.unselect dom_target(@quantity) %>
+4 -4
View File
@@ -1,8 +1,8 @@
<%#= render partial: 'form_repath' %>
<% @readouts.each do |r| %>
<%= turbo_stream.disable_all "select.quantity option[value='#{r.quantity_id}']" %>
<%= turbo_stream.disable dom_target(r.quantity) %>
<% end %>
<%= turbo_stream.before :readouts_form do %>
<%= render partial: 'form_repath' %>
<%= turbo_stream.append :readouts do %>
<%= render partial: 'form', collection: @readouts, as: :readout %>
<% end %>
<%= turbo_stream.enable :create_measurement_button if @prev_quantities.empty? %>
+8 -11
View File
@@ -1,22 +1,19 @@
<%= 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 %>
<td style="--depth:<%= @unit.base_id? ? 1 : 0 %>">
<%= form.text_field :symbol, required: true, autofocus: true, size: 12 %>
</td>
<%= form.text_field :symbol, autofocus: true, class: 'hstretch', required: true %>
<td>
<%= form.text_area :description, cols: 30, rows: 1, escape: false %>
</td>
<td class="number">
<%= form.number_field :multiplier, required: true, size: 10, min: :step if @unit.base_id? %>
</td>
<% if @unit.base_id? %>
<%= form.number_field :multiplier, class: 'hstretch', required: true %>
<% end %>
<td><%= form.text_area :description, class: 'hstretch', escape: false, rows: 1 %>
<td class="actions">
<td class="flex">
<%= form.button %>
<%= image_link_to t(:cancel), "close-outline", units_path, class: 'dangerous',
name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %>
</td>
<td></td>
<td>
<% end %>
<% end %>
+21 -20
View File
@@ -1,31 +1,32 @@
<%= tag.tr id: dom_id(unit),
ondragstart: "dragStart(event)", ondragend: "dragEnd(event)",
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drag_path: rebase_unit_path(unit),
drop_id: dom_id(unit.base || unit),
drop_id_param: "unit[base_id]"} do %>
<tr id="<%= dom_target(unit) %>"
ondragstart="dragStart(event)" ondragend="dragEnd(event)"
ondragover="dragOver(event)" ondrop="drop(event)"
ondragenter="dragEnter(event)" ondragleave="dragLeave(event)"
data-drag-path="<%= rebase_unit_path(unit) %>"
data-drop-id="<%= dom_target(unit.base || unit) %>"
data-drop-id-param="<%= field_name(:unit, :base_id) %>">
<td class="link" style="--depth:<%= unit.base_id? ? 1 : 0 %>">
<%= link_to unit, edit_unit_path(unit), onclick: 'this.blur();', data: {turbo_stream: true} %>
</td>
<td><%= unit.description %></td>
<td class="number"><%= unit.multiplier.to_html %></td>
<td style="--depth:<%= unit.base_id? ? 1 : 0 %>">
<%= link_to unit, edit_unit_path(unit), class: 'link', onclick: 'this.blur();',
data: {turbo_stream: true} %>
<%# Closing <td> tag required to avoid whitespace inclusion for testing. %>
<td class="ralign"><%= unit.multiplier.to_html if unit.base_id? %></td>
<td><%= unit.description %>
<% if current_user.at_least(:active) %>
<td class="actions">
<td class="flex">
<% unless unit.base_id? %>
<%= image_link_to t('.new_subunit'), 'plus-outline', new_unit_path(unit),
id: dom_id(unit, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>
id: dom_target(:new, unit, :link), onclick: 'this.blur();',
data: {turbo_stream: true} %>
<% end %>
<%= image_button_to_if unit.movable?, t('.destroy'), 'delete-outline', unit_path(unit),
method: :delete %>
</td>
<%= image_button_to_if unit.movable?, t('.destroy'), 'delete-outline',
unit_path(unit), method: :delete %>
<% if unit.movable? %>
<td class="handle" draggable="true">&#x283F</td>
<td class="handle" draggable="true">&#x283F
<% else %>
<td></td>
<td>
<% end %>
<% end %>
<% end %>
+2 -3
View File
@@ -1,4 +1,3 @@
<%= turbo_stream.close_form dom_id(@unit.base || Unit, :new) %>
<%= turbo_stream.remove :no_items %>
<%= turbo_stream.close_form dom_target(:new, @unit.base || :unit) %>
<%= turbo_stream.replace @unit.base if @unit.base_id? %>
<%= @before ? turbo_stream.before(@before, @unit) : turbo_stream.append(:units, @unit) %>
<%= turbo_stream.before @before || :endmark, @unit %>
-1
View File
@@ -1,3 +1,2 @@
<%= turbo_stream.replace @unit.base if @unit.base_id? %>
<%= turbo_stream.remove @unit %>
<%= turbo_stream.append(:units, render_no_items) if current_user.units.empty? %>
+4 -5
View File
@@ -1,13 +1,12 @@
<% ids = {row: dom_id(@unit, :edit),
hidden_row: dom_id(@unit),
<% ids = {row: dom_target(:edit, @unit),
hidden_row: dom_target(@unit),
link: nil,
form_tag: dom_id(@unit, :edit, :form)} %>
form_tag: dom_target(:edit, @unit, :form)} %>
<%= turbo_stream.append :unit_form do %>
<%= turbo_stream.append_all 'body' do %>
<%- tabular_form_with model: @unit, html: {id: ids[:form_tag]} do %>
<% end %>
<% end %>
<%= turbo_stream.hide ids[:hidden_row] %>
<%= turbo_stream.remove ids[:row] %>
<%= turbo_stream.after @unit, partial: 'form', locals: ids -%>
+24 -23
View File
@@ -1,32 +1,33 @@
<div class="rightside buttongrid">
<div class="rightside-area buttongrid">
<% if current_user.at_least(:active) %>
<%= 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: :new_unit_link, onclick: 'this.blur();', data: {turbo_stream: true} %>
<% 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>
<%= tag.div id: :unit_form %>
<table class="main-area items-table">
<caption class="hint"><%= t '.no_items' %></caption>
<table class="main items">
<thead>
<tbody>
<tr>
<th><%= User.human_attribute_name(:symbol).capitalize %></th>
<th><%= User.human_attribute_name(:description).capitalize %></th>
<th><%= User.human_attribute_name(:multiplier).capitalize %></th>
<% if current_user.at_least(:active) %>
<th><%= t :actions %></th>
<th></th>
<% end %>
</tr>
<%= tag.tr id: "unit_", hidden: true,
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drop_id: "unit_", drop_id_param: "unit[base_id]"} do %>
<th colspan="5"><%= t '.top_level_drop' %></th>
<th class="hmintext"><%= Unit.human_attribute_name(:symbol) %>
<th class="hminnumber"><%= Unit.human_attribute_name(:multiplier) %>
<th class="hexpand"><%= Unit.human_attribute_name(:description) %>
<% if current_user.at_least(:active) %>
<th><%= t :actions %>
<th>
<tr id="unit_" hidden
ondragover="dragOver(event)" ondrop="drop(event)"
ondragenter="dragEnter(event)" ondragleave="dragLeave(event)"
data-drop-id="unit_" data-drop-id-param="<%= field_name(:unit, :base_id) %>">
<th colspan="5"><%= t '.top_level_drop' %>
<% end %>
</thead>
<tbody id="units">
<%= render(@units) || render_no_items %>
</tbody>
<%= render @units %>
<%# Hidden rows with IDs allow simplier row insertion by using only
turbo_stream #after/#before instead of #after + #prepend/#before + #append. %>
<tr id="endmark" hidden>
</table>
+6 -11
View File
@@ -1,20 +1,15 @@
<% dom_obj = @unit.base || @unit %>
<% ids = {row: dom_id(dom_obj, :new),
<% target_id = dom_target(:new, @unit.base || :unit) %>
<% ids = {row: target_id,
hidden_row: nil,
link: dom_id(dom_obj, :new, :link),
form_tag: dom_id(dom_obj, :new, :form)} %>
link: dom_target(target_id, :link),
form_tag: dom_target(target_id, :form)} %>
<%= turbo_stream.disable ids[:link] -%>
<%= turbo_stream.append :unit_form do %>
<%= turbo_stream.append_all 'body' do %>
<%- tabular_form_with model: @unit, html: {id: ids[:form_tag]} do |form| %>
<%= form.hidden_field :base_id if @unit.base_id? %>
<% end %>
<% end %>
<% if @unit.base_id? %>
<%= turbo_stream.remove ids[:row] %>
<%= turbo_stream.after @unit.base, partial: 'form', locals: ids %>
<% else %>
<%= turbo_stream.prepend :units, partial: 'form', locals: ids %>
<% end %>
<%= turbo_stream.after @unit.base || :unit_, partial: 'form', locals: ids %>
+4 -3
View File
@@ -1,4 +1,5 @@
<% [@previous_base, @unit.base].uniq.compact.each do |unit| %>
<%= turbo_stream.replace unit %>
<% end %>
<%= turbo_stream.remove @unit %>
<%= turbo_stream.replace @previous_base if @previous_base %>
<%= turbo_stream.replace @unit.base if @unit.base_id? && (@previous_base.id != @unit.base_id) %>
<%= @before ? turbo_stream.before(@before, @unit) : turbo_stream.append(:units, @unit) %>
<%= turbo_stream.before @before || :endmark, @unit %>
+1 -1
View File
@@ -1,3 +1,3 @@
<%= turbo_stream.close_form dom_id(@unit, :edit) %>
<%= turbo_stream.close_form dom_target(:edit, @unit) %>
<%= turbo_stream.replace @unit.base if @unit.base_id? %>
<%= turbo_stream.replace @unit %>
@@ -0,0 +1 @@
<% flash.discard %>
@@ -1,8 +0,0 @@
<div class="main">
<%= labelled_form_for resource, url: user_confirmation_path do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, autocomplete: "email",
value: (resource.pending_reconfirmation? ? resource.unconfirmed_email : resource.email) %>
<%= f.submit t(:resend_confirmation) %>
<% end %>
</div>
+12 -16
View File
@@ -1,36 +1,32 @@
<table class="main items" id="users">
<table class="main-area items-table" id="users">
<thead>
<tr>
<th><%= User.human_attribute_name(:email).capitalize %></th>
<th><%= User.human_attribute_name(:status).capitalize %></th>
<th><%= User.human_attribute_name(:confirmed_at).capitalize %></th>
<th>
<%= User.human_attribute_name(:created_at).capitalize %>&nbsp;<sup>UTC</sup>
</th>
<th><%= User.human_attribute_name(:email) %></th>
<th><%= User.human_attribute_name(:status) %></th>
<th><%= User.human_attribute_name(:confirmed_at) %></th>
<th><pre><%= User.human_attribute_name(:created_at) %> <sup>(UTC)</sup></pre></th>
<th><%= t :actions %></th>
</tr>
</thead>
<tbody>
<% @users.each do |user| %>
<tr>
<td class="link"><%= link_to user, user_path(user) %></td>
<td><%= link_to user, user_path(user), class: 'link' %></td>
<td>
<% if user == current_user %>
<%= user.status %>
<% else %>
<%= form_for user do |u| %>
<%= u.select :status, User.statuses.keys, {}, autocomplete: "off",
onchange: "this.form.requestSubmit();" %>
<%= u.select :status, User.statuses.keys, {}, autocomplete: 'off',
onchange: 'this.form.requestSubmit();' %>
<% end %>
<% end %>
</td>
<td class="svg">
<%= svg_tag "pictograms/checkbox-marked-outline" if user.confirmed_at.present? %>
</td>
<td><%= user.created_at.to_fs(:db_without_sec) %></td>
<td class="actions">
<td><%= svg_tag 'checkbox-marked-outline' if user.confirmed_at.present? %></td>
<td><pre><%= l user.created_at, format: :without_tz %></pre></td>
<td class="flex">
<% 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 %>
</td>
</tr>
@@ -1,5 +1,5 @@
<p>Welcome <%= @email %>!</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>
@@ -0,0 +1 @@
<% flash.discard %>
+10 -11
View File
@@ -1,13 +1,12 @@
<div class="main">
<%= labelled_form_for resource, url: user_password_path, html: {method: :put} do |f| %>
<%= f.hidden_field :reset_password_token, label: false %>
<%= labeled_form_for resource, url: user_password_path,
html: {method: :put, class: 'main-area', data: {turbo: false}} do |f| %>
<%= f.password_field :password, label: t(".new_password"), required: true, size: 30,
minlength: @minimum_password_length, autofocus: true, autocomplete: "new-password",
hint: t("users.minimum_password_length", count: @minimum_password_length) %>
<%= f.password_field :password_confirmation, label: t(".password_confirmation"),
required: true, size: 30, minlength: @minimum_password_length, autocomplete: "off" %>
<%= f.hidden_field :reset_password_token %>
<%= f.submit t(".update_password") %>
<% end %>
</div>
<%= 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 %>
-7
View File
@@ -1,7 +0,0 @@
<div class="main">
<%= labelled_form_for resource, url: user_password_path do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, autocomplete: "email" %>
<%= f.submit t(:recover_password) %>
<% end %>
</div>
+30
View File
@@ -0,0 +1,30 @@
<% content_for :navigation, flush: true do %>
<%= link_to svg_tag('arrow-left-bold-outline', t(:back)),
request.referer.present? ? :back : root_path, class: 'tab' %>
<% end %>
<div class="rightside-area buttongrid">
<%#= TODO: Disallow/disable deletion for last admin account, image_button_to_if %>
<%= image_button_to t('.delete'), 'account-remove-outline', user_registration_path,
form_class: 'tools-area', method: :delete, data: {turbo: false},
onclick: {confirm: t('.confirm_delete')} %>
</div>
<%= labeled_form_for resource, url: registration_path(resource),
html: {method: :patch, class: 'main-area'} do |f| %>
<%= f.email_field :email, size: 30, autofocus: true, autocomplete: 'off' %>
<% if resource.pending_reconfirmation? %>
<%= f.text_field :unconfirmed_email, readonly: true,
confirmation_sent_at: l(resource.confirmation_sent_at) %>
<% end %>
<%= f.select :status, User.statuses, readonly: true %>
<%= f.password_field :password, size: 30, autocomplete: 'new-password',
minlength: @minimum_password_length %>
<%= f.password_field :password_confirmation, size: 30, autocomplete: 'off',
minlength: @minimum_password_length %>
<%= f.submit %>
<% end %>
+16
View File
@@ -0,0 +1,16 @@
<%= labeled_form_for resource, url: user_registration_path,
html: {class: 'main-area', onsubmit: 'formValidate(event)'} do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true,
autocomplete: 'email' %>
<%= f.password_field :password, required: true, size: 30,
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(:register), data: {turbo: false} %>
<%= image_button_tag t(:resend_confirmation), 'email-sync-outline',
class: 'auxiliary', formaction: user_confirmation_path, formnovalidate: true,
data: {validate: f.field_id(:email)} %>
<% end %>

Some files were not shown because too many files have changed in this diff Show More