Compare commits

..

3 Commits

Author SHA1 Message Date
barbie-bot 39900958ad Fix controller tests and SQLite compatibility for defaults_diff
Test infrastructure:
- Allow www.example.com host in test env (ActionDispatch::HostAuthorization
  was blocking all integration test requests)
- Include Devise::Test::IntegrationHelpers in ActionDispatch::IntegrationTest
  so tests can sign in with sign_in(user)

Controller tests:
- Rewrite UsersControllerTest to match actual routes/actions (no new/create/
  edit/destroy); sign in as admin; test update-self rejection via turbo_stream
- Fix Default::UnitsControllerTest to sign in before requesting the index

SQLite compatibility in Unit#defaults_diff:
- Hoist the inner "units" CTE to the outer WITH RECURSIVE level (fixes nested
  WITH syntax error) — this was the existing TODO in the code
- Use Unit.joins(...) for the recursive part instead of a raw Arel::SelectManager
  so the SQLite visitor does not wrap it in parentheses inside UNION ALL
- Drop the named "units" CTE (conflicts with the table name under WITH RECURSIVE
  in SQLite); apply the user/defaults scope directly on the base case
- Qualify GROUP BY columns to avoid ambiguity when bases_units is joined
- Qualify ORDER BY :multiplier/:symbol to avoid ambiguity (Unit.ordering)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:12:53 +00:00
barbie-bot 80e0170876 Use committed database.yml instead of generating it in CI
The repo's config/database.yml already handles both SQLite (default) and
MySQL (DB_ADAPTER=mysql) via ERB. Remove the redundant steps that overwrote
it with a hardcoded version, and pass DB_ADAPTER=mysql for the MySQL job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:12:53 +00:00
barbie-bot 0085377fad Add multi-adapter test support: SQLite + MySQL via Gitea Actions and rake task
- .gitea/workflows/test.yml: two parallel CI jobs (SQLite and MySQL),
  each generates its own database.yml inline and runs the test suite
- lib/tasks/test_multi_db.rake: `rails test:all_adapters` runs both
  adapters sequentially using DATABASE_URL to switch at runtime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:12:53 +00:00
132 changed files with 1282 additions and 1966 deletions
+74
View File
@@ -0,0 +1,74 @@
name: Tests
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
test-sqlite:
name: Tests (SQLite)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
env:
BUNDLE_WITH: "sqlite:development:test"
- name: Set up test database
run: bin/rails db:create db:schema:load
env:
RAILS_ENV: test
- name: Run tests
run: bin/rails test
env:
RAILS_ENV: test
CI: "true"
test-mysql:
name: Tests (MySQL)
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: ""
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
MYSQL_DATABASE: fixin_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
env:
BUNDLE_WITH: "mysql:development:test"
- name: Set up test database
run: bin/rails db:schema:load
env:
RAILS_ENV: test
DB_ADAPTER: mysql
- name: Run tests
run: bin/rails test
env:
RAILS_ENV: test
CI: "true"
DB_ADAPTER: mysql
+1 -5
View File
@@ -15,9 +15,6 @@
/config/master.key
/config/puma.rb
# Ignore test database.
/db/test.sqlite3
# Ignore all logfiles and tempfiles.
/log/*
/tmp/*
@@ -33,7 +30,7 @@
/tmp/restart.txt
# Ignore user files.
# Ignore user files
/.bash_history
/.byebug_history
/.config
@@ -41,7 +38,6 @@
/.lesshst
/.local
/.mysql_history
/.sqlite_history
/.ssh
/.vim
/.viminfo
-76
View File
@@ -1,76 +0,0 @@
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.
+11 -8
View File
@@ -1,14 +1,14 @@
source "https://rubygems.org"
# The requirement for the Ruby version comes from Rails.
gem "rails", "~> 8.1.4"
# The requirement for the Ruby version comes from Rails
gem "rails", "~> 7.2.3"
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", "~> 5.0.4"
gem "devise"
gem "importmap-rails"
gem "turbo-rails", "~> 2.0"
@@ -42,8 +42,11 @@ group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
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"
# Remove minitest version restriction after error fixed:
# railties-7.2.3/lib/rails/test_unit/line_filtering.rb:7:in `run':
# wrong number of arguments (given 3, expected 1..2) (ArgumentError)
# from /var/www/.gem/ruby/3.3.0/gems/minitest-6.0.2/lib/minitest.rb:473:in
# `block (2 levels) in run_suite'
gem "minitest", "< 6"
end
+144 -150
View File
@@ -1,93 +1,85 @@
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:
action_text-trix (2.1.19)
railties
actioncable (8.1.4)
actionpack (= 8.1.4)
activesupport (= 8.1.4)
actioncable (7.2.3)
actionpack (= 7.2.3)
activesupport (= 7.2.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.4)
actionpack (= 8.1.4)
activejob (= 8.1.4)
activerecord (= 8.1.4)
activestorage (= 8.1.4)
activesupport (= 8.1.4)
actionmailbox (7.2.3)
actionpack (= 7.2.3)
activejob (= 7.2.3)
activerecord (= 7.2.3)
activestorage (= 7.2.3)
activesupport (= 7.2.3)
mail (>= 2.8.0)
actionmailer (8.1.4)
actionpack (= 8.1.4)
actionview (= 8.1.4)
activejob (= 8.1.4)
activesupport (= 8.1.4)
actionmailer (7.2.3)
actionpack (= 7.2.3)
actionview (= 7.2.3)
activejob (= 7.2.3)
activesupport (= 7.2.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.4)
actionview (= 8.1.4)
activesupport (= 8.1.4)
actionpack (7.2.3)
actionview (= 7.2.3)
activesupport (= 7.2.3)
cgi
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
racc
rack (>= 2.2.4, < 3.3)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
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)
actiontext (7.2.3)
actionpack (= 7.2.3)
activerecord (= 7.2.3)
activestorage (= 7.2.3)
activesupport (= 7.2.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.4)
activesupport (= 8.1.4)
actionview (7.2.3)
activesupport (= 7.2.3)
builder (~> 3.1)
cgi
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.4)
activesupport (= 8.1.4)
activejob (7.2.3)
activesupport (= 7.2.3)
globalid (>= 0.3.6)
activemodel (8.1.4)
activesupport (= 8.1.4)
activerecord (8.1.4)
activemodel (= 8.1.4)
activesupport (= 8.1.4)
activemodel (7.2.3)
activesupport (= 7.2.3)
activerecord (7.2.3)
activemodel (= 7.2.3)
activesupport (= 7.2.3)
timeout (>= 0.4.0)
activestorage (8.1.4)
actionpack (= 8.1.4)
activejob (= 8.1.4)
activerecord (= 8.1.4)
activesupport (= 8.1.4)
activestorage (7.2.3)
actionpack (= 7.2.3)
activejob (= 7.2.3)
activerecord (= 7.2.3)
activesupport (= 7.2.3)
marcel (~> 1.0)
activesupport (8.1.4)
activesupport (7.2.3)
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)
uri (>= 0.13.1)
addressable (2.9.0)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
base64 (0.3.0)
bcrypt (3.1.22)
bigdecimal (4.1.3)
bcrypt (3.1.21)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
builder (3.3.0)
byebug (13.0.0)
@@ -101,155 +93,155 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
concurrent-ruby (1.3.8)
cgi (0.5.1)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.7)
crass (1.0.6)
date (3.5.1)
devise (5.0.4)
devise (5.0.2)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 7.0)
responders
warden (~> 1.2.3)
drb (2.2.3)
erb (6.0.7)
erb (6.0.2)
erubi (1.13.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)
ffi (1.17.3-aarch64-linux-gnu)
ffi (1.17.3-aarch64-linux-musl)
ffi (1.17.3-arm-linux-gnu)
ffi (1.17.3-arm-linux-musl)
ffi (1.17.3-arm64-darwin)
ffi (1.17.3-x86_64-darwin)
ffi (1.17.3-x86_64-linux-gnu)
ffi (1.17.3-x86_64-linux-musl)
globalid (1.3.0)
activesupport (>= 6.1)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
importmap-rails (2.2.3)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.9.4)
irb (1.18.0)
io-console (0.8.2)
irb (1.17.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.25.2)
loofah (2.25.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.1)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.2.1)
marcel (1.1.0)
matrix (0.4.3)
mini_mime (1.1.5)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
minitest (5.27.0)
mysql2 (0.5.7)
bigdecimal
net-imap (0.6.7)
net-imap (0.6.3)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.4.0)
net-protocol (0.2.2)
timeout
net-smtp (0.5.2)
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.4-aarch64-linux-gnu)
nokogiri (1.19.1-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-aarch64-linux-musl)
nokogiri (1.19.1-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.4-arm-linux-gnu)
nokogiri (1.19.1-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-arm-linux-musl)
nokogiri (1.19.1-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.19.4-arm64-darwin)
nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-darwin)
nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu)
nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-musl)
nokogiri (1.19.1-x86_64-linux-musl)
racc (~> 1.4)
orm_adapter (0.5.0)
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)
pg (1.6.3)
pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
public_suffix (7.0.5)
psych (5.3.1)
date
stringio
public_suffix (7.0.2)
puma (6.6.1)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.7)
rack-session (2.1.2)
rack (3.2.5)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
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)
rails (7.2.3)
actioncable (= 7.2.3)
actionmailbox (= 7.2.3)
actionmailer (= 7.2.3)
actionpack (= 7.2.3)
actiontext (= 7.2.3)
actionview (= 7.2.3)
activejob (= 7.2.3)
activemodel (= 7.2.3)
activerecord (= 7.2.3)
activestorage (= 7.2.3)
activesupport (= 7.2.3)
bundler (>= 1.15.0)
railties (= 8.1.4)
railties (= 7.2.3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.4)
actionpack (= 8.1.4)
activesupport (= 8.1.4)
railties (7.2.3)
actionpack (= 7.2.3)
activesupport (= 7.2.3)
cgi
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rake (13.4.2)
rbs (4.2.0)
logger
prism (>= 1.6.0)
tsort
rdoc (8.1.0)
rake (13.3.1)
rdoc (7.2.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
psych (>= 4.0.0)
tsort
regexp_parser (2.13.1)
reline (0.7.0)
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
responders (3.2.1)
responders (3.2.0)
actionpack (>= 7.0)
railties (>= 7.0)
rubyzip (3.7.0)
rexml (3.4.4)
rubyzip (3.2.2)
sassc (2.4.0)
ffi (~> 1.9)
sassc-rails (2.1.2)
@@ -259,52 +251,54 @@ GEM
sprockets-rails
tilt
securerandom (0.4.1)
selenium-webdriver (4.50.0)
selenium-webdriver (4.41.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sprockets (4.4.1)
concurrent-ruby (~> 1.1)
sprockets (4.2.2)
concurrent-ruby (~> 1.0)
logger
rack (>= 2.2.4, < 4)
sprockets-rails (3.5.2)
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
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)
sqlite3 (2.9.0-aarch64-linux-gnu)
sqlite3 (2.9.0-aarch64-linux-musl)
sqlite3 (2.9.0-arm-linux-gnu)
sqlite3 (2.9.0-arm-linux-musl)
sqlite3 (2.9.0-arm64-darwin)
sqlite3 (2.9.0-x86_64-darwin)
sqlite3 (2.9.0-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl)
stringio (3.2.0)
thor (1.5.0)
tilt (2.9.0)
timeout (0.6.1)
tilt (2.7.0)
timeout (0.6.0)
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.3.0)
actionview (>= 8.0.0)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 8.0.0)
railties (>= 6.0.0)
websocket (1.2.11)
websocket-driver (0.8.2)
websocket-driver (0.8.0)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.8.3)
zeitwerk (2.7.5)
PLATFORMS
aarch64-linux-gnu
@@ -319,13 +313,13 @@ PLATFORMS
DEPENDENCIES
byebug
capybara
devise (~> 5.0.4)
i18n!
devise
importmap-rails
minitest (< 6)
mysql2 (~> 0.5)
pg (~> 1.5)
puma (~> 6.0)
rails (~> 8.1.4)
rails (~> 7.2.3)
sassc-rails
selenium-webdriver
sprockets-rails
+17 -43
View File
@@ -16,10 +16,14 @@ 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. SQLite, MySQL >= 8.0) supporting:
* database (e.g. 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:
@@ -85,10 +89,14 @@ Running
### Standalone Rails server + Apache proxy
Copy and customize Puma config template if required:
Customize Puma config template:
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
@@ -115,26 +123,9 @@ Contributing
### Gems
Install development and testing gems, including at least MySQL and SQLite
database adapters:
Apart from database adapter, install development and testing gems:
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
bundle config --local with mysql development test
### Configuration
@@ -147,29 +138,20 @@ assets.
### Database
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:
Grant database user privileges for development and test environments,
possibly with different Ruby versions:
> 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 ...
@@ -182,22 +164,14 @@ Tests need to be run from within toplevel application directory:
bundle exec rails test:system
* system test(s) with seed or test name specified:
* system test(s) with seed/test name specified:
bundle exec rails test:system --include test_add_unit --seed 64690
bundle exec rails test:system --seed 64690 --name test_add_unit
* all tests from one file, optionally with seed:
* all tests from one file, with optional 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/
-1
View File
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 152 B

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

Before

Width:  |  Height:  |  Size: 278 B

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

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

After

Width:  |  Height:  |  Size: 167 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 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>

After

Width:  |  Height:  |  Size: 293 B

Before

Width:  |  Height:  |  Size: 231 B

After

Width:  |  Height:  |  Size: 231 B

Before

Width:  |  Height:  |  Size: 148 B

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

Before

Width:  |  Height:  |  Size: 316 B

After

Width:  |  Height:  |  Size: 316 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

+287 -267
View File
@@ -18,12 +18,10 @@
/* 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.
* differently depending on context (e.g. form)
*
* NOTE: style in a modular way, similar to how CSS @scope would be used,
* to make transition easier once @scope is widely available. */
* NOTE: Style in a modular way, similar to how CSS @scope would be used,
* to make transition easier once @scope is widely available */
:root {
--color-focus-gray: #f3f3f3;
--color-border-gray: #dddddd;
@@ -55,41 +53,17 @@
:focus-visible {
outline: none;
}
/* 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.
* 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;
@@ -99,24 +73,56 @@ input,
select {
text-align: inherit;
}
a,
button,
input[type=submit] {
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);
display: flex;
fill: var(--color-gray);
font-weight: bold;
}
.button,
button,
input[type=submit] {
font-size: 0.8rem;
padding: 0.6em 0.5em;
width: fit-content;
}
input:not([type=submit]):not([type=checkbox]),
select,
summary,
textarea {
padding: 0.2em 0.4em;
}
.button,
button,
input,
select,
summary,
textarea {
border: 1px solid var(--color-gray);
border: solid 1px 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;
[name=cancel],
.auxiliary {
border-color: var(--color-border-gray);
color: var(--color-nav-gray);
fill: var(--color-nav-gray);
}
input[type=checkbox],
svg,
textarea {
margin: 0;
margin: 0
}
input[type=checkbox] {
accent-color: var(--color-blue);
@@ -124,20 +130,17 @@ input[type=checkbox] {
-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. */
/* Hide spin buttons in input number fields */
/* TODO: add spin buttons inside input[number]: before (-) and after (+) input */
input[type=number] {
appearance: textfield;
-moz-appearance: textfield;
text-align: end;
}
input::-webkit-inner-spin-button {
-webkit-appearance: none;
@@ -146,117 +149,37 @@ 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);
.button > svg,
.tab > svg,
button > svg {
height: 1.4em;
width: 1.4em;
}
table input,
table select,
table textarea {
padding-block: 0.375em;
.button > svg:not(:last-child),
.tab > svg:not(:last-child),
button > svg:not(:last-child) {
margin-right: 0.2em;
}
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;
/* TODO: move normal non-button links (<a>:hover/:focus) styling here (i.e.
* page-wide, top-level) and remove from table.items - as the style should be
* same everywhere */
.button:focus-visible,
button:focus-visible,
input[type=submit]:focus-visible {
background-color: var(--color-focus-gray);
}
input:focus-visible,
select:focus-visible,
select:focus-within,
/* TODO: how to achieve `summary:focus-within` for `::details-content`? */
/* 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;
}
/* `.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;
}
.button,
.tab {
align-items: center;
color: var(--color-gray);
display: flex;
fill: var(--color-gray);
font-weight: bold;
}
.button {
border: 1px solid var(--color-gray);
border-radius: 0.25em;
font-size: 0.8rem;
padding: 0.6em 0.5em;
width: fit-content;
}
.link {
color: inherit;
text-decoration: underline 1px var(--color-border-gray);
text-underline-offset: 0.25em;
}
.auxiliary {
border-color: var(--color-nav-gray);
color: var(--color-nav-gray);
fill: var(--color-nav-gray);
}
table .button {
border-color: var(--color-border-gray);
font-weight: normal;
height: 100%;
padding: 0.4em;
}
.button:focus-visible,
.tab:focus-visible,
.tab:hover {
background-color: var(--color-focus-gray);
}
.button:hover {
.button:hover,
button:hover,
input[type=submit]:hover {
background-color: var(--color-blue);
border-color: var(--color-blue);
color: white;
@@ -266,17 +189,32 @@ table .button {
background-color: var(--color-red);
border-color: var(--color-red);
}
.link:focus-visible {
text-decoration-color: var(--color-gray);
input:hover,
select:hover,
summary:hover,
textarea:hover {
border-color: var(--color-blue);
outline: solid 1px var(--color-blue);
}
.link:hover {
color: var(--color-blue);
text-decoration-color: var(--color-blue);
select:hover,
summary:hover {
cursor: pointer;
}
input:invalid,
select:invalid,
textarea:invalid {
border-color: var(--color-red);
outline: solid 1px var(--color-red);
}
input[type=text]:read-only,
textarea:read-only {
border: none;
padding-inline: 0;
}
/* NOTE: collapse gaps around empty rows (`topside`) once possible with
* `grid-collapse` property and remove alternative `grid-template-areas`.
/* NOTE: collapse gaps around empty rows (`topside`) once possible
* with grid-collapse property and remove alternative grid-template
* https://github.com/w3c/csswg-drafts/issues/5813 */
body {
display: grid;
@@ -284,16 +222,16 @@ body {
grid-template-areas:
"header header header"
"nav nav nav"
"leftside topside rightside"
"leftside main rightside";
grid-template-columns: 1fr minmax(max-content, 2fr) 1fr;
font-family: system-ui;
margin: 0.4em;
}
body:has(> .topside-area) {
body:not(:has(.topside-area)) {
grid-template-areas:
"header header header"
"nav nav nav"
"leftside topside rightside"
"leftside main rightside";
}
@@ -309,14 +247,18 @@ header {
margin-inline-start: 4%;
}
.navigation > .tab {
border-bottom: 2px solid var(--color-nav-gray);
border-bottom: solid 2px var(--color-nav-gray);
flex: 1;
font-size: 1rem;
justify-content: center;
padding-block: 0.4em;
}
.navigation > .tab:hover,
.navigation > .tab:focus-visible {
background-color: var(--color-focus-gray);
}
.navigation > .tab.active {
border-bottom: 4px solid var(--color-blue);
border-bottom: solid 4px var(--color-blue);
color: var(--color-blue);
fill: var(--color-blue);
}
@@ -348,7 +290,7 @@ header {
#flashes {
display: grid;
row-gap: 0.4em;
gap: 0.2em;
grid-template-columns: 1fr auto auto auto 1fr;
left: 0;
pointer-events: none;
@@ -364,42 +306,49 @@ header {
display: grid;
grid-column: 2/5;
grid-template-columns: subgrid;
line-height: 2.2em;
pointer-events: auto;
}
.flash::before {
filter: invert(1);
.flash.alert:before {
content: url('pictograms/alert-outline.svg');
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('check-circle-outline.svg');
.flash.notice:before {
content: url('pictograms/check-circle-outline.svg');
height: 1.4em;
margin: 0 0.5em;
width: 1.4em;
}
.flash.notice {
border-color: var(--color-blue);
background-color: var(--color-blue);
}
.flash svg {
cursor: pointer;
fill: white;
height: 2.2em;
opacity: 0.6;
padding: 0.4em 0.5em;
width: 2.4em;
.flash > div {
grid-column: 2;
}
.flash svg:hover {
/* NOTE: currently flash button inherits some unnecessary styles from generic
* button. */
.flash > button {
border: none;
color: inherit;
cursor: pointer;
font-size: 1.4em;
font-weight: bold;
grid-column: 3;
opacity: 0.6;
padding: 0.2em 0.4em;
}
.flash > button:hover {
opacity: 1;
}
/* TODO: Hover over invalid should work like in measurements (thin vs thick border) */
.labeled-form {
align-items: center;
display: grid;
@@ -416,7 +365,7 @@ header {
.labeled-form label.required {
font-weight: bold;
}
/* Don't style `label.error + input` if case already covered by `input:invalid`. */
/* Don't style `label.error + input` if case already covered by input:invalid */
.labeled-form label.error {
color: var(--color-red);
}
@@ -436,120 +385,200 @@ header {
.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. */
* `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;
}
.items-table {
/* TODO: remove .items class (?) and make 'form table' work properly */
table.items {
border-spacing: 0;
border: 1px solid var(--color-border-gray);
border: solid 1px var(--color-border-gray);
border-radius: 0.25em;
font-size: 0.85rem;
text-align: left;
}
.items-table thead {
table:not(:has(tr)) {
display: none;
}
table.items thead {
font-size: 0.8rem;
}
.items-table thead,
.items-table tbody tr:hover {
table.items thead,
table.items tbody tr:hover {
background-color: var(--color-focus-gray);
}
.items-table th {
padding: 0.75em 0 0.75em 1em;
table.items th {
padding-block: 0.75em;
text-align: center;
}
.items-table th:last-child {
padding-inline-end: 0.4em;
table.items th,
table.items td {
padding-inline: 1em 0;
}
.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;
}
.items-table :is(form, input, select, textarea):only-child {
margin-inline-start: calc(-0.4em - 0.9px);
}
/* 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) {
/* For <a> to fill <td> completely, we use an ::after pseudoelement. */
table.items td.link {
padding: 0;
position: relative;
}
.items-table .link::after {
table.items td.link a {
color: inherit;
font: inherit;
}
table.items td.link a::after {
content: '';
inset: -1px 0 0 0;
inset: 0;
position: absolute;
}
.items-table .flex {
table.items td:first-child {
padding-inline-start: calc(1em + var(--depth) * 0.8em);
}
table.items td:has(input, select, textarea) {
padding-inline-start: calc(0.6em - 0.9px);
}
table.items td:first-child:has(input, select, textarea) {
padding-inline-start: calc(0.6em + var(--depth) * 0.8em - 0.9px);
}
table.items th:last-child {
padding-inline-end: 0.4em;
}
table.items 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;
}
table.items .actions {
display: flex;
gap: 0.4em;
justify-content: end;
}
.items-table .dropzone {
table.items .actions.centered {
justify-content: center;
}
table.items tr.dropzone {
position: relative;
}
.items-table .dropzone::after {
table.items tr.dropzone::after {
content: '';
inset: 1px 0 0 0;
position: absolute;
outline: 2px dashed var(--color-blue);
outline: dashed 2px var(--color-blue);
outline-offset: -1px;
z-index: var(--z-index-table-row-outline);
}
.items-table .handle {
cursor: grab;
table.items td.handle {
cursor: move;
}
.items-table .form td {
table.items tr.form td {
vertical-align: top;
}
.items-table td:not(:first-child),
/* TODO: replace :hover:focus-visible combos with proper LOVE stye order */
/* TODO: Update table styling: simplify selectors, deduplicate, remove non-font rem. */
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),
.grayed {
color: var(--color-table-gray);
fill: var(--color-gray);
fill: var(--color-table-gray);
}
.items-table td:has(> svg:only-child) {
table.items svg {
height: 1rem;
vertical-align: middle;
width: 1rem;
}
table.items svg:last-child {
height: 1.2rem;
width: 1.2rem;
}
table.items td.svg {
text-align: center;
}
table.items td.number {
text-align: right;
}
table.items .button,
table.items button,
table.items input[type=submit] {
font-weight: normal;
height: 100%;
padding: 0.4em;
}
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 table.items {
border: none;
}
form table.items td {
border: none;
text-align: left;
vertical-align: middle;
}
form table.items td:first-child {
color: inherit;
}
.center {
.centered {
margin: 0 auto;
}
.extendedright {
margin-right: auto;
}
.hexpand {
width: 100%;
}
.flex {
.hflex {
display: flex;
gap: 0.8em;
}
.flex.reverse {
.hflex.reverse {
flex-direction: row-reverse;
}
.flex.vertical {
flex-direction: column;
.hflex.centered {
justify-content: center;
}
.hint {
color: var(--color-table-gray);
@@ -557,18 +586,21 @@ header {
font-size: 0.9rem;
text-align: center;
}
.hmin50 {
min-width: 50%;
.vflex {
display: flex;
gap: 0.8em;
flex-direction: column;
}
.italic {
color: var(--color-gray);
font-style: italic;
}
.ralign {
text-align: right;
}
.rextend {
margin-right: auto;
[disabled] {
/* label:has(input[disabled]) {
* TODO: disabled checkbox blue square focus removal; disabled label styling;
* focused label styling (currently only checkbox has focus)
* */
border-color: var(--color-border-gray) !important;
color: var(--color-border-gray) !important;
cursor: not-allowed;
fill: var(--color-border-gray) !important;
pointer-events: none;
}
@@ -580,15 +612,15 @@ summary {
align-items: center;
color: var(--color-gray);
display: flex;
gap: 0.4em;
gap: 0.2em;
height: 100%;
white-space: nowrap;
}
summary::before {
background-color: currentColor;
background-color: #000;
content: "";
height: 1em;
mask-image: url('chevron-down.svg');
mask-image: url('pictograms/chevron-down.svg');
mask-size: cover;
width: 1em;
}
@@ -597,7 +629,7 @@ summary:has(.button) {
padding-inline-end: 0;
}
summary .button {
border: 1px solid var(--color-border-gray);
border: solid 1px var(--color-border-gray);
border-radius: inherit;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
@@ -608,15 +640,15 @@ summary span {
width: 100%;
}
details[open] summary::before {
transform: scaleY(-1);
transform: rotate(180deg);
}
summary::marker {
padding-left: 0.25em;
}
/* NOTE: use `details[open]::details-content` once widely available. */
/* NOTE: use details[open]::details-content once widely available */
details[open] ul {
background-color: white;
border: 1px solid var(--color-border-gray);
background: white;
border: solid 1px var(--color-border-gray);
border-radius: 0.25em;
box-shadow: 1px 1px 3px var(--color-border-gray);
margin: -1px 0 0 0;
@@ -638,15 +670,3 @@ li input[type=checkbox] {
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;
}
+3 -3
View File
@@ -55,9 +55,9 @@ class ApplicationController < ActionController::Base
private
def render_errors(record)
target = record.new_record? ? [:new, record.parent || record.class] : [:edit, record]
render 'errors', assigns: {record: record, target: target}
def render_no_content(record)
helpers.render_errors(record)
render html: nil, layout: true
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
+9 -12
View File
@@ -21,7 +21,7 @@ class QuantitiesController < ApplicationController
if @quantity.save
@before = @quantity.successive
@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,21 +43,18 @@ class QuantitiesController < ApplicationController
permitted = params.require(:quantity).permit(:parent_id)
@previous_ancestors = @quantity.ancestors
if @quantity.update(permitted)
@ancestors = @quantity.ancestors
@self_and_progenies = @quantity.with_progenies
@before = @self_and_progenies.last.successive
else
# Until UI blocks all disallowed reparents, render error messages if present.
flash.now.alert = @quantity.errors.full_messages
render :nothing
end
# 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
end
def destroy
@quantity.destroy!
@ancestors = @quantity.ancestors
flash.now.notice = t('.success', quantity: @quantity)
flash.now[:notice] = t('.success', quantity: @quantity)
end
private
+1 -1
View File
@@ -6,7 +6,7 @@ class ReadoutsController < ApplicationController
def new
@quantities -= @prev_quantities
# TODO: raise ParameterInvalid if new_quantities.empty?
@readouts = @quantities.map { |q| q.readouts.build }
@readouts = current_user.readouts.build(@quantities.map { |q| {quantity: q} })
@user_units = current_user.units.ordered
+24 -15
View File
@@ -1,9 +1,10 @@
class UnitsController < ApplicationController
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 only: :new do
find_unit if params[:id].present?
end
before_action :find_unit, only: [:edit, :update, :rebase, :destroy]
before_action except: [:index] do
before_action except: :index do
raise AccessForbidden unless current_user.at_least(:active)
end
@@ -16,12 +17,12 @@ class UnitsController < ApplicationController
end
def create
@unit = current_user.units.new(params.expect(Unit::ATTRIBUTES))
@unit = current_user.units.new(unit_params)
if @unit.save
@before = @unit.successive
flash.now.notice = t('.success', unit: @unit)
flash.now[:notice] = t('.success', unit: @unit)
else
render_errors @unit
render :new
end
end
@@ -29,32 +30,40 @@ class UnitsController < ApplicationController
end
def update
if @unit.update(params.except(:base_id).expect(Unit::ATTRIBUTES))
flash.now.notice = t('.success', unit: @unit)
if @unit.update(unit_params.except(:base_id))
flash.now[:notice] = t('.success', unit: @unit)
else
render_errors @unit
render :edit
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
unit_params = params.expect(unit: :base_id)
unit_params.merge!(multiplier: 1.0) if unit_params[:base_id].blank?
permitted = params.require(:unit).permit(:base_id)
permitted.merge!(multiplier: 1) if permitted[:base_id].blank? && @unit.multiplier != 1
@previous_base = @unit.base
@unit.update!(unit_params)
@unit.update!(permitted)
@before = @unit.successive
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
+42 -35
View File
@@ -12,12 +12,6 @@ module ApplicationHelper
labeled_field_for(method, options) { super }
end
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 labeled_field_for(method, options)
@@ -40,7 +34,7 @@ module ApplicationHelper
# 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 = I18n.config.with(**handler) { deep_translate(method, **options) }
translation.presence || "#{builder.translation}:"
end
end
@@ -91,32 +85,31 @@ 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.type_for_attribute(method).limit
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
super
end
RUBY_EVAL
end
def number_field(method, options = {})
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] ||= 6
options[:step] ||= :any
options[:value] = object.public_send("#{method}_before_type_cast")
attr_type = object.type_for_attribute(method)
if attr_type.type == :decimal
options[:value] = object.public_send(method)&.to_scientific
options[:step] ||= BigDecimal(10).power(-attr_type.scale)
options[:max] ||= BigDecimal(10).power(attr_type.precision - attr_type.scale) -
options[:step]
options[:min] = options[:min] == :step ? options[:step] : options[:min]
options[:min] ||= -options[:max]
end
super
end
def button(value = nil, options = {}, &block)
# #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])
)
# button does not use #objectify_options
options.merge!(@options.slice(:form))
super
end
@@ -124,7 +117,11 @@ module ApplicationHelper
def submit_default_value
svg_name = object ? (object.persisted? ? 'update' : 'plus-circle-outline') : ''
@template.svg_tag(svg_name, super)
@template.svg_tag("pictograms/#{svg_name}", super)
end
def except_pattern(value, pattern = nil)
"(?!^#{Regexp.escape(value)}$)" + (pattern || ".*")
end
end
@@ -135,19 +132,19 @@ module ApplicationHelper
record_object, options = nil, record_object if record_object.is_a?(Hash)
extra_options = {builder: TabularFormBuilder, skip_default_ids: true}
options = merge_attributes(options, extra_options)
# TODO: set error message with setCustomValidity instead of rendering to flash?
render_errors(record_object || record_name)
fields_for(record_name, record_object, **options, &block)
end
def tabular_form_with(**options, &block)
extra_options = {builder: TabularFormBuilder, class: 'tabular-form',
html: {autocomplete: 'off'}}
extra_options = {builder: TabularFormBuilder, html: {autocomplete: 'off'}}
form_with(**merge_attributes(options, extra_options), &block)
end
def svg_tag(source, label = nil, options = {})
label, options = nil, label if label.is_a? Hash
svg_tag = tag.svg(**options) do
tag.use(href: image_path("#{source}.svg") + '#icon')
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
@@ -163,7 +160,7 @@ module ApplicationHelper
menu_tabs.map do |name, image, status, css_class|
if current_user.at_least(status)
link_to svg_tag(image, t("#{name}.navigation")),
link_to svg_tag("pictograms/#{image}", t("#{name}.navigation")),
{controller: "/#{name}", action: "index"},
class: class_names('tab', css_class, active: name == current_tab)
end
@@ -195,19 +192,29 @@ 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)
if current_page?(options, method: [:get, :post])
# NOTE: Starting from Rails 8.1.0, below condition can be replaced with:
# current_page?(options, method: [:get, :post])
if request.path == url_for(options)
html_options = html_options.deep_merge DISABLED_ATTRIBUTES
end
link_to name, options, html_options
end
def render_flash
def render_errors(records)
# Conversion of flash to Array only required because of Devise
# TODO: override Devise message setting to Array()?
flash[:alert] = Array(flash[:alert])
Array(records).each { |record| flash[:alert] += record.errors.full_messages }
end
def render_flash_messages
flash.map do |entry, messages|
# Conversion of flash to Array only required because of Devise.
# Conversion of flash to Array only required because of Devise
Array(messages).map do |message|
tag.div class: "flash #{entry}" do
tag.span(sanitize(message)) +
svg_tag('close-outline', {onclick: "this.parentElement.remove()"})
# TODO: change button text to svg to make it aligned vertically
tag.div(sanitize(message)) + tag.button(sanitize("&times;"), tabindex: -1,
onclick: "this.parentElement.remove();")
end
end
end.join.html_safe
@@ -239,7 +246,7 @@ module ApplicationHelper
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 = svg_tag("pictograms/#{image}", name) if image
[name, html_options]
end
+2 -2
View File
@@ -1,9 +1,9 @@
module QuantitiesHelper
def quantities_check_boxes(quantities)
def quantities_check_boxes
# Closing <details> on focusout event depends on relatedTarget for internal
# actions being non-null. To ensure this, all top-layer elements of
# ::details-content must accept focus, e.g. <label> needs tabindex="-1" */
collection_check_boxes(nil, :quantity, quantities, :id, :to_s_with_depth,
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
+11 -80
View File
@@ -1,18 +1,14 @@
// Configure your import map in config/importmap.rb. Read more:
// https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
// TODO: access `data-` attributes through
// `keyname in`/`dataset.keyname`/`delete dataset.keyname`
// Show page if hidden for testing purposes.
/* Hide page before loaded for testing purposes */
function showPage(event) {
document.documentElement.style.visibility="visible"
}
document.addEventListener('turbo:load', showPage)
// <details> event handlers.
function detailsChange(event) {
var target = event.currentTarget
var count = target.querySelectorAll('input:checked:not([disabled])').length
@@ -28,7 +24,7 @@ function detailsChange(event) {
}
window.detailsChange = detailsChange
// Close open <details> when focus lost.
/* Close open <details> when focus lost */
function detailsClose(event) {
if (!event.relatedTarget ||
event.relatedTarget.closest("details") != event.currentTarget) {
@@ -41,8 +37,6 @@ 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;
@@ -55,58 +49,28 @@ function formValidate(event) {
}
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
// Turbo stream actions.
const FORM_CONTROLS = ["BUTTON", "FIELDSET", "INPUT", "OPTGROUP", "OPTION", "SELECT",
"TEXTAREA"]
/* Turbo stream actions */
Turbo.StreamElement.prototype.disableElement = function(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")
}
}
Turbo.StreamActions.disable = function() {
this.targetElements.forEach((e) => { this.disableElement(e) })
}
Turbo.StreamElement.prototype.enableElement = function(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.
// Assume 'tabindex' is not used explicitly, so removing it is safe
element.removeAttribute("tabindex")
}
}
Turbo.StreamActions.enable = function() {
this.targetElements.forEach((e) => { this.enableElement(e) })
}
// TODO: change to visibility = collapse to avoid width change?
/* TODO: change to visibility = collapse to avoid width change? */
Turbo.StreamActions.hide = function() {
this.targetElements.forEach((e) => { e.style.display = "none" })
}
@@ -123,7 +87,7 @@ Turbo.StreamActions.collapse = function() {
Turbo.StreamActions.close_form = function() {
this.targetElements.forEach((e) => {
// Move focus if there's no focus or focus inside form being closed.
/* Move focus if there's no focus or focus inside form being closed */
const focused = document.activeElement
if (!focused || (focused == document.body) || e.contains(focused)) {
let nextForm = e.parentElement.querySelector(`#${e.id} ~ tr:has([autofocus])`)
@@ -148,35 +112,6 @@ Turbo.StreamActions.unselect = function() {
})
}
Turbo.StreamActions.validate = function() {
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()
})
}
// Keyboard event processing handlers.
function formProcessKey(event) {
switch (event.key) {
case "Escape":
@@ -203,7 +138,7 @@ function detailsProcessKey(event) {
var button = event.currentTarget.querySelector("button:not([disabled])")
if (button) {
button.click()
// Autofocus won't be respected unless target is blurred.
// Autofocus won't be respected unless target is blurred
event.target.blur()
event.preventDefault()
event.stopPropagation()
@@ -213,8 +148,7 @@ function detailsProcessKey(event) {
}
window.detailsProcessKey = detailsProcessKey;
// Items table drag and drop support.
/* Items table drag and drop support */
var lastEnterTime
function dragStart(event) {
lastEnterTime = event.timeStamp
@@ -224,8 +158,7 @@ function dragStart(event) {
})
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.setDragImage(row, event.x - rowRectangle.left, event.y - rowRectangle.top)
event.dataTransfer.dropEffect = "move"
}
window.dragStart = dragStart
@@ -235,11 +168,9 @@ window.dragStart = dragStart
* * 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)
@@ -257,7 +188,7 @@ 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.
// 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")
-2
View File
@@ -1,6 +1,4 @@
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
@@ -1,3 +0,0 @@
class Note < ApplicationRecord
ATTRIBUTES = [:text]
end
+1 -3
View File
@@ -6,14 +6,12 @@ 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]},
@@ -99,7 +97,7 @@ class Quantity < ApplicationRecord
}
def to_s
tag.pre name
name
end
def to_s_with_depth
-2
View File
@@ -4,6 +4,4 @@ class Readout < ApplicationRecord
belongs_to :user
belongs_to :quantity
belongs_to :unit
# TODO: validate quantity.user_id == unit.user_id != NULL
end
+30 -25
View File
@@ -1,9 +1,8 @@
class Unit < ApplicationRecord
ATTRIBUTES = {unit: [:symbol, :description, :multiplier, :base_id]}
ATTRIBUTES = [:symbol, :description, :multiplier, :base_id]
belongs_to :user, optional: true
belongs_to :base, optional: true, class_name: "Unit"
alias_method :parent, :base
has_many :subunits, class_name: "Unit", inverse_of: :base,
dependent: :restrict_with_error
@@ -15,8 +14,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.0}, unless: :base
validates :multiplier, numericality: {greater_than: 0.0}, if: :base
validates :multiplier, numericality: {equal_to: 1}, unless: :base
validates :multiplier, numericality: {greater_than: 0, precision: true, scale: true}, if: :base
scope :defaults, ->{ where(user: nil) }
scope :defaults_diff, ->{
@@ -27,10 +26,8 @@ class Unit < ApplicationRecord
other_bases_units = arel_table.alias('other_bases_units')
sub_units = arel_table.alias('sub_units')
# TODO: move inner 'with' CTE to outer 'with recursive' - it can have multiple
# CTEs, even non recursive ones.
Unit.with_recursive(actionable_units: [
Unit.with(units: self.or(Unit.defaults)).left_joins(:base)
self.or(Unit.defaults).left_joins(:base)
.where.not(
# Exclude Units that are/have default counterpart
Arel::SelectManager.new.project(1).from(other_units)
@@ -66,8 +63,14 @@ class Unit < ApplicationRecord
),
# Fill base Units to display proper hierarchy. Duplicates will be removed
# by final group() - can't be deduplicated with UNION due to 'portable' field.
arel_table.join(actionable_units).on(actionable_units[:base_id].eq(arel_table[:id]))
.project(arel_table[Arel.star], Arel::Nodes.build_quoted(nil).as('portable'))
# Use ActiveRecord::Relation (not a raw SelectManager) so the SQLite Arel
# visitor does not wrap it in parentheses inside the UNION ALL CTE body.
Unit.joins(
arel_table.create_join(
actionable_units,
arel_table.create_on(actionable_units[:base_id].eq(arel_table[:id]))
)
).select(arel_table[Arel.star], Arel::Nodes.build_quoted(nil).as('portable'))
]).select(units: [:base_id, :symbol])
.select(
units[:id].minimum.as('id'), # can be ANY_VALUE()
@@ -75,24 +78,26 @@ class Unit < ApplicationRecord
Arel::Nodes.build_quoted(1).as('multiplier'), # disregard multiplier when sorting
units[:portable].minimum.as('portable')
)
.from(units).group(:base_id, :symbol)
.from(units).group(units[:base_id], units[:symbol])
}
scope :ordered, ->{
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]
])
left_outer_joins(:base).order(ordering)
}
def self.ordering
[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]]
end
before_destroy do
# TODO: disallow destruction if any object depends on this unit
nil
end
def to_s
tag.pre symbol
symbol
end
def movable?
@@ -103,21 +108,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[:unit] - [:symbol, :base_id])
params = slice(ATTRIBUTES - [:symbol, :base_id])
Unit.find_or_initialize_by(user: recipient, symbol: symbol)
.update!(base: recipient_base, **params)
end
def successive
units = Unit.arel_table
Unit.with(units_with_lag: 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(:units_with_lag).as(:units))
.where(units[:lag_id].eq(id)).take
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
end
end
@@ -1,7 +0,0 @@
<%= turbo_stream.validate 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="flex">
<td class="actions">
<% unless unit.portable.nil? %>
<% if unit.default? %>
<%= image_button_to_if unit.portable?, t('.import'), 'download-outline',
+1 -1
View File
@@ -8,7 +8,7 @@
class: 'tools-area' %>
</div>
<table class="main-area items-table">
<table class="main-area items">
<thead>
<tr>
<th><%= Unit.human_attribute_name(:symbol) %></th>
+3 -3
View File
@@ -23,10 +23,10 @@
</head>
<body>
<header class="flex">
<header class="hflex">
<%= image_link_to t(".source_code"), "code-braces", source_code_url %>
<%= image_link_to t(".issue_tracker"), "bug-outline", issue_tracker_url,
class: "rextend" %>
class: "extendedright" %>
<% if user_signed_in? %>
<%= image_link_to_unless_current(current_user, "account-wrench-outline",
edit_user_registration_path) %>
@@ -44,7 +44,7 @@
</header>
<div id="flashes">
<%= render_flash %>
<%= render_flash_messages %>
</div>
<%# Allows overwriting/clearing navigation menu for some views %>
@@ -1,6 +1,6 @@
<%# Render unconditionally, to clear previous flash messages. %>
<%= turbo_stream.update :flashes do %>
<%= render_flash %>
<% end %>
<%= yield %>
<%# Some views may convert ActiveRecord errors to flashes, render at the end. %>
<%= turbo_stream.update :flashes do %>
<%= render_flash_messages %>
<% end %>
+22 -31
View File
@@ -1,39 +1,30 @@
<%= 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>
</tr>
<% end %>
</tbody>
class: 'topside-area vflex', html: {onkeydown: 'formProcessKey(event)'} do |form| %>
<table class="items centered">
<tbody id="readouts"></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 -->
<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">
<div class="hflex">
<%# TODO: right-click selection %>
<details id="quantity_select" class="hexpand" open
onkeydown="detailsProcessKey(event)">
<summary autofocus>
<!-- TODO: Set content with CSS when span empty to avoid duplication -->
<span data-prompt="<%= t('.select_quantity') %>">
<%= t('.select_quantity') %>
</span>
<%= image_button_tag t(:apply), "update", name: nil, disabled: true,
formaction: new_readout_path, formmethod: :get, formnovalidate: true,
data: {turbo_stream: true} %>
</summary>
<ul><%= quantities_check_boxes %></ul>
</details>
<%= form.button id: :create_measurement_button, disabled: true -%>
</div>
<div class="hflex reverse">
<%= image_link_to t(:cancel), "close-outline", measurements_path, name: :cancel,
class: 'auxiliary dangerous', onclick: render_turbo_stream('form_close') %>
class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</div>
<% end %>
+1 -1
View File
@@ -9,7 +9,7 @@
<%= form.text_area :description, cols: 30, rows: 1, escape: false %>
</td>
<td class="flex">
<td class="actions">
<%= form.button %>
<%= image_link_to t(:cancel), "close-outline", quantities_path, class: 'dangerous',
name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %>
+4 -4
View File
@@ -5,14 +5,14 @@
data: {drag_path: reparent_quantity_path(quantity), drop_id: dom_id(quantity),
drop_id_param: "quantity[parent_id]"} do %>
<td style="--depth:<%= quantity.depth %>">
<%= link_to quantity, edit_quantity_path(quantity), class: 'link',
onclick: 'this.blur();', data: {turbo_stream: true} %>
<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>
<% if current_user.at_least(:active) %>
<td class="flex">
<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} %>
+2 -3
View File
@@ -8,14 +8,13 @@
class: 'tools-area' %>
</div>
<%# TODO: remove? form can be inserted directly, e.g. at the end of index %>
<%= tag.div class: 'main-area', id: :quantity_form %>
<table class="main-area items-table">
<table class="main-area items">
<thead>
<tr>
<th><%= Quantity.human_attribute_name(:name) %></th>
<th class="hexpand"><%= Quantity.human_attribute_name(:description) %></th>
<th><%= Quantity.human_attribute_name(:description) %></th>
<% if current_user.at_least(:active) %>
<th><%= t :actions %></th>
<th></th>
+15 -14
View File
@@ -1,24 +1,25 @@
<%# TODO: add readout reordering by dragging %>
<%= tabular_fields_for 'readouts[]', readout do |form| %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout) do %>
<td>
<%# 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: readout_counter == 0 %>
</td>
<td>
<%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) },
{prompt: '', disabled: '', selected: ''}, required: true %>
</td>
<td class="flex">
<td class="actions">
<%# TODO: change to _link_ after giving up displaying relative paths %>
<%= image_button_tag '', 'delete-outline', class: 'dangerous', name: nil,
formaction: discard_readouts_path(readout.quantity),
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>
</td>
<td>
<%= readout.quantity.relative_pathname(@superquantity) %>
</td>
<td>
<%= form.number_field :value, required: true,
size: readout.type_for_attribute(:value).precision / 2,
autofocus: readout_counter == 0 %>
</td>
<td>
<%= form.hidden_field :quantity_id %>
<%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) },
{prompt: t('.select_unit'), disabled: '', selected: ''}, required: true %>
</td>
<% end %>
<% end %>
+3 -3
View File
@@ -8,11 +8,11 @@
<td>
<%= form.text_area :description, cols: 30, rows: 1, escape: false %>
</td>
<td>
<%= form.number_field :multiplier, required: true if @unit.base_id? %>
<td class="number">
<%= form.number_field :multiplier, required: true, size: 10, min: :step if @unit.base_id? %>
</td>
<td class="flex">
<td class="actions">
<%= form.button %>
<%= image_link_to t(:cancel), "close-outline", units_path, class: 'dangerous',
name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %>
+4 -5
View File
@@ -6,15 +6,14 @@
drop_id: dom_id(unit.base || unit),
drop_id_param: "unit[base_id]"} do %>
<td style="--depth:<%= unit.base_id? ? 1 : 0 %>">
<%= link_to unit, edit_unit_path(unit), class: 'link', onclick: 'this.blur();',
data: {turbo_stream: true} %>
<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="ralign"><%= unit.multiplier.to_html if unit.base_id? %></td>
<td class="number"><%= unit.multiplier.to_html %></td>
<% if current_user.at_least(:active) %>
<td class="flex">
<td class="actions">
<% 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} %>
+1 -1
View File
@@ -1,4 +1,4 @@
<%= turbo_stream.close_form dom_target(:new, @unit.base || :unit) %>
<%= turbo_stream.close_form dom_id(@unit.base || Unit, :new) %>
<%= turbo_stream.remove :no_items %>
<%= turbo_stream.replace @unit.base if @unit.base_id? %>
<%= @before ? turbo_stream.before(@before, @unit) : turbo_stream.append(:units, @unit) %>
+3 -3
View File
@@ -1,7 +1,7 @@
<% ids = {row: dom_target(:edit, @unit),
hidden_row: dom_target(@unit),
<% ids = {row: dom_id(@unit, :edit),
hidden_row: dom_id(@unit),
link: nil,
form_tag: dom_target(:edit, @unit, :form)} %>
form_tag: dom_id(@unit, :edit, :form)} %>
<%= turbo_stream.append :unit_form do %>
<%- tabular_form_with model: @unit, html: {id: ids[:form_tag]} do %>
+2 -3
View File
@@ -7,14 +7,13 @@
class: 'tools-area' %>
</div>
<%# TODO: remove? form can be inserted directly, e.g. at the end of index %>
<%= tag.div id: :unit_form %>
<table class="main-area items-table">
<table class="main-area items">
<thead>
<tr>
<th><%= Unit.human_attribute_name(:symbol) %></th>
<th class="hexpand"><%= Unit.human_attribute_name(:description) %></th>
<th><%= Unit.human_attribute_name(:description) %></th>
<th><%= Unit.human_attribute_name(:multiplier) %></th>
<% if current_user.at_least(:active) %>
<th><%= t :actions %></th>
+4 -4
View File
@@ -1,8 +1,8 @@
<% target_id = dom_target(:new, @unit.base || :unit) %>
<% ids = {row: target_id,
<% dom_obj = @unit.base || @unit %>
<% ids = {row: dom_id(dom_obj, :new),
hidden_row: nil,
link: dom_target(target_id, :link),
form_tag: dom_target(target_id, :form)} %>
link: dom_id(dom_obj, :new, :link),
form_tag: dom_id(dom_obj, :new, :form)} %>
<%= turbo_stream.disable ids[:link] -%>
+1 -1
View File
@@ -1,4 +1,4 @@
<%= 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) %>
<%= 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) %>
+1 -1
View File
@@ -1,3 +1,3 @@
<%= turbo_stream.close_form dom_target(:edit, @unit) %>
<%= turbo_stream.close_form dom_id(@unit, :edit) %>
<%= turbo_stream.replace @unit.base if @unit.base_id? %>
<%= turbo_stream.replace @unit %>
+8 -6
View File
@@ -1,17 +1,17 @@
<table class="main-area items-table" id="users">
<table class="main-area items" id="users">
<thead>
<tr>
<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><%= User.human_attribute_name(:created_at) %>&nbsp;<sup>(UTC)</sup></th>
<th><%= t :actions %></th>
</tr>
</thead>
<tbody>
<% @users.each do |user| %>
<tr>
<td><%= link_to user, user_path(user), class: 'link' %></td>
<td class="link"><%= link_to user, user_path(user) %></td>
<td>
<% if user == current_user %>
<%= user.status %>
@@ -22,9 +22,11 @@
<% end %>
<% end %>
</td>
<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">
<td class="svg">
<%= svg_tag 'pictograms/checkbox-marked-outline' if user.confirmed_at.present? %>
</td>
<td><%= l user.created_at, format: :without_tz %></td>
<td class="actions">
<% if allow_disguise?(user) %>
<%= image_link_to t('.disguise'), 'incognito', disguise_user_path(user) %>
<% end %>
@@ -1 +1,2 @@
<%# For some reason flash messages are duplicated in bot flash and flash.now %>
<% flash.discard %>
+1 -1
View File
@@ -1,5 +1,5 @@
<% content_for :navigation, flush: true do %>
<%= link_to svg_tag('arrow-left-bold-outline', t(:back)),
<%= link_to svg_tag('pictograms/arrow-left-bold-outline', t(:back)),
request.referer.present? ? :back : root_path, class: 'tab' %>
<% end %>
+1
View File
@@ -10,6 +10,7 @@
<%= f.submit t(:register), data: {turbo: false} %>
<%# TODO: fix button text color after change link -> button %>
<%= image_button_tag t(:resend_confirmation), 'email-sync-outline',
class: 'auxiliary', formaction: user_confirmation_path, formnovalidate: true,
data: {validate: f.field_id(:email)} %>
+2 -1
View File
@@ -1,5 +1,6 @@
<% content_for :navigation, flush: true do %>
<%= link_to svg_tag('arrow-left-bold-outline', t(:back)), users_path, class: 'tab' %>
<%= link_to svg_tag('pictograms/arrow-left-bold-outline', t(:back)), users_path,
class: 'tab' %>
<% end %>
<%= labeled_form_for @user, html: {class: 'main-area'} do |f| %>
+1 -1
View File
@@ -8,7 +8,7 @@
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>
<div class="flex">
<div class="actions">
<%= f.submit "Resend unlock instructions" %>
</div>
<% end %>
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env ruby
require_relative "../config/boot"
require "active_support/continuous_integration"
CI = ActiveSupport::ContinuousIntegration
require_relative "../config/ci.rb"
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env ruby
exec "./bin/rails", "server", *ARGV
Executable → Regular
View File
-29
View File
@@ -1,29 +0,0 @@
#!/usr/bin/ruby33
#
# This file was generated by RubyGems.
#
# The application 'nokogiri' is installed as part of a gem, and
# this file is here to facilitate running it.
#
require 'rubygems'
Gem.use_gemdeps
version = ">= 0.a"
str = ARGV.first
if str
str = str.b[/\A_(.*)_\z/, 1]
if str and Gem::Version.correct?(str)
version = str
ARGV.shift
end
end
if Gem.respond_to?(:activate_bin_path)
load Gem.activate_bin_path('nokogiri', 'nokogiri', version)
else
gem "nokogiri", version
load Gem.bin_path("nokogiri", "nokogiri", version)
end
+5 -7
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env ruby
require "fileutils"
# path to your application root.
APP_ROOT = File.expand_path("..", __dir__)
def system!(*args)
system(*args, exception: true)
system(*args) || abort("\n== Command #{args} failed ==")
end
FileUtils.chdir APP_ROOT do
@@ -13,6 +14,7 @@ FileUtils.chdir APP_ROOT do
# Add necessary setup steps to this file.
puts "== Installing dependencies =="
system! "gem install bundler --conservative"
system("bundle check") || system!("bundle install")
# puts "\n== Copying sample files =="
@@ -22,14 +24,10 @@ FileUtils.chdir APP_ROOT do
puts "\n== Preparing database =="
system! "bin/rails db:prepare"
system! "bin/rails db:reset" if ARGV.include?("--reset")
puts "\n== Removing old logs and tempfiles =="
system! "bin/rails log:clear tmp:clear"
unless ARGV.include?("--skip-server")
puts "\n== Starting development server =="
STDOUT.flush # flush the output before exec(2) so that it displays
exec "bin/dev"
end
puts "\n== Restarting application server =="
system! "bin/rails restart"
end
+7 -13
View File
@@ -18,17 +18,14 @@ require "rails/test_unit/railtie"
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
require_relative '../lib/default_settings_strategy'
module FixinMe
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 8.1
config.load_defaults 7.0
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w[assets tasks])
# Autoload lib/, required e.g. for core library extensions.
# https://guides.rubyonrails.org/autoloading_and_reloading_constants.html#config-autoload-lib-ignore.
config.autoload_lib(ignore: %w(assets tasks))
# Configuration for the application, engines, and railties goes here.
#
@@ -41,19 +38,16 @@ module FixinMe
config.action_dispatch.rescue_responses['ApplicationController::AccessForbidden'] = :forbidden
config.action_dispatch.rescue_responses['ApplicationController::ParameterInvalid'] = :unprocessable_entity
# Set default migrations parameters.
config.active_record.migration_strategy = DefaultSettingsStrategy
# SETUP: Below settings need to be updated on a per-installation basis.
#
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = {host: 'localhost', protocol: 'https'}
# URL to use in sent e-mails.
config.action_mailer.default_url_options = {host: 'localhost', :protocol => 'https'}
# https://guides.rubyonrails.org/configuring.html#config-action-mailer-delivery-method
config.action_mailer.delivery_method = :sendmail
# List of hosts this app is available at.
# https://guides.rubyonrails.org/configuring.html#actiondispatch-hostauthorization
config.hosts |= ['localhost']
config.hosts += ['localhost', 'example.com', IPAddr.new('1.2.3.4/32')]
# Email address of admin account
config.admin = 'admin@localhost'
-20
View File
@@ -1,20 +0,0 @@
# Run using bin/ci
CI.run do
step "Setup", "bin/setup --skip-server"
step "Security: Importmap vulnerability audit", "bin/importmap audit"
step "Tests: Rails", "bin/rails test"
step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant"
# Optional: Run system tests
# step "Tests: System", "bin/rails test:system"
# Optional: set a green GitHub commit status to unblock PR merge.
# Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`.
# if success?
# step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
# else
# failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
# end
end
+12 -30
View File
@@ -24,45 +24,27 @@
# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database
# for a full overview on how database connection configuration can be specified.
default: &default
pool: <%= ENV.fetch('RAILS_MAX_THREADS', 3) %>
#mysql_default: &mysql_default
# <<: *default
# username: fixinme
# password: Some-password1%
# host: 127.0.0.1
# encoding: utf8mb4
# collation: utf8mb4_0900_as_ci
adapter: mysql2
encoding: utf8mb4
collation: utf8mb4_0900_as_ci
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
username: fixinme
password: Some-password1%
socket: /run/mysqld/mysqld.sock
production:
<<: *default
adapter: sqlite3
database: db/production.sqlite3
database: fixinme
# Unless you're planning on developing the application, you can skip/remove
# Unless you're planning on developing the application, you can skip
# configurations for development and test databases altogether.
#development:
# <<: *mysql_default
# adapter: mysql2
# <<: *default
# database: fixinme_dev
# Warning: The database(s) defined as "test" will be erased and
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
#test:
# <<: *mysql_default
# adapter: mysql2
# <<: *default
# database: fixinme_test
# Multiple test databases can be provided. When more than one test db is
# present, every test task (test, test:models, test:system, etc.) runs against
# all of them.
#test:
# mysql2:
# <<: *mysql_default
# adapter: mysql2
# database: fixinme_test
# sqlite3:
# <<: *default
# adapter: sqlite3
# database: db/test.sqlite3
+25 -21
View File
@@ -3,8 +3,10 @@ require "active_support/core_ext/integer/time"
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Make code changes take effect immediately without server restart.
config.enable_reloading = true
# In the development environment your application's code is reloaded any time
# it changes. This slows down response time but is perfect for development
# since you don't have to restart the web server when you make code changes.
config.cache_classes = false
# Do not eager load code on boot.
config.eager_load = false
@@ -12,52 +14,54 @@ Rails.application.configure do
# Show full error reports.
config.consider_all_requests_local = true
# Enable server timing.
# Enable server timing
config.server_timing = true
# Enable/disable Action Controller caching. By default Action Controller caching is disabled.
# Run rails dev:cache to toggle Action Controller caching.
# Enable/disable caching. By default caching is disabled.
# Run rails dev:cache to toggle caching.
if Rails.root.join("tmp/caching-dev.txt").exist?
config.action_controller.perform_caching = true
config.action_controller.enable_fragment_cache_logging = true
config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
config.cache_store = :memory_store
config.public_file_server.headers = {
"Cache-Control" => "public, max-age=#{2.days.to_i}"
}
else
config.action_controller.perform_caching = false
end
# Change to :null_store to avoid any caching.
config.cache_store = :memory_store
config.cache_store = :null_store
end
# Don't care if the mailer can't send.
config.action_mailer.raise_delivery_errors = false
# Make template changes take effect immediately.
config.action_mailer.perform_caching = false
# Print deprecation notices to the Rails logger.
config.active_support.deprecation = :log
# Raise exceptions for disallowed deprecations.
config.active_support.disallowed_deprecation = :raise
# Tell Active Support which deprecation messages to disallow.
config.active_support.disallowed_deprecation_warnings = []
# Raise an error on page load if there are pending migrations.
config.active_record.migration_error = :page_load
# Highlight code that triggered database queries in logs.
config.active_record.verbose_query_logs = true
# Append comments with runtime information tags to SQL queries in logs.
config.active_record.query_log_tags_enabled = true
# Highlight code that enqueued background job in logs.
config.active_job.verbose_enqueue_logs = true
# Highlight code that triggered redirect in logs.
config.action_dispatch.verbose_redirect_logs = true
# Suppress logger output for asset requests.
config.assets.quiet = true
# Raises error for missing translations.
# config.i18n.raise_on_missing_translations = true
# Annotate rendered view with file names.
config.action_view.annotate_rendered_view_with_filenames = true
# config.action_view.annotate_rendered_view_with_filenames = true
# Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true
# Uncomment if you wish to allow Action Cable access from any origin.
# config.action_cable.disable_request_forgery_protection = true
end
+46 -51
View File
@@ -4,83 +4,78 @@ Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Code is not reloaded between requests.
config.enable_reloading = false
config.cache_classes = true
# Eager load code on boot for better performance and memory savings (ignored by Rake tasks).
# Eager load code on boot. This eager loads most of Rails and
# your application in memory, allowing both threaded web servers
# and those relying on copy on write to perform better.
# Rake tasks automatically ignore this option for performance.
config.eager_load = true
# Full error reports are disabled.
config.consider_all_requests_local = false
# Turn on fragment caching in view templates.
# Full error reports are disabled and caching is turned on.
config.consider_all_requests_local = false
config.action_controller.perform_caching = true
# Cache assets for far-future expiry since they are all digest stamped.
config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }
# Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"]
# or in config/master.key. This key is used to decrypt credentials (and other encrypted files).
# config.require_master_key = true
# Disable serving static files from the `/public` folder by default since
# Apache or NGINX already handles this.
config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present?
# Compress CSS using a preprocessor.
# config.assets.css_compressor = :sass
# Do not fallback to assets pipeline if a precompiled asset is missed.
config.assets.compile = false
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.asset_host = "http://assets.example.com"
# Assume all access to the app is happening through a SSL-terminating reverse proxy.
config.assume_ssl = true
# Specifies the header that your server uses for sending files.
# config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache
# config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
config.force_ssl = true
# config.force_ssl = true
# Skip http-to-https redirect for the default health check endpoint.
# config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }
# Include generic and useful information about system operation, but avoid logging too much
# information to avoid inadvertent exposure of personally identifiable information (PII).
config.log_level = :info
# Log to STDOUT with the current request id as a default log tag.
# Prepend all log lines with the following tags.
config.log_tags = [ :request_id ]
config.logger = ActiveSupport::TaggedLogging.logger(STDOUT)
# Change to "debug" to log everything (including potentially personally-identifiable information!).
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
# Prevent health checks from clogging up the logs.
config.silence_healthcheck_path = "/up"
# Don't log any deprecations.
config.active_support.report_deprecations = false
# Replace the default in-process memory cache store with a durable alternative.
# Use a different cache store in production.
# config.cache_store = :mem_cache_store
# Replace the default in-process and non-durable queuing backend for Active Job.
# config.active_job.queue_adapter = :resque
config.action_mailer.perform_caching = false
# Ignore bad email addresses and do not raise email delivery errors.
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
# config.action_mailer.raise_delivery_errors = false
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
# config.action_mailer.smtp_settings = {
# user_name: Rails.application.credentials.dig(:smtp, :user_name),
# password: Rails.application.credentials.dig(:smtp, :password),
# address: "smtp.example.com",
# port: 587,
# authentication: :plain
# }
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# the I18n.default_locale when a translation cannot be found).
config.i18n.fallbacks = true
# Don't log any deprecations.
config.active_support.report_deprecations = false
# Use default logging formatter so that PID and timestamp are not suppressed.
config.log_formatter = ::Logger::Formatter.new
# Use a different logger for distributed setups.
# require "syslog/logger"
# config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new "app-name")
if ENV["RAILS_LOG_TO_STDOUT"].present?
logger = ActiveSupport::Logger.new(STDOUT)
logger.formatter = config.log_formatter
config.logger = ActiveSupport::TaggedLogging.new(logger)
end
# Do not dump schema after migrations.
config.active_record.dump_schema_after_migration = false
# Only use :id for inspections in production.
config.active_record.attributes_for_inspect = [ :id ]
# Enable DNS rebinding protection and other `Host` header attacks.
# config.hosts = [
# "example.com", # Allow requests from example.com
# /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
# ]
#
# Skip DNS rebinding protection for the default health check endpoint.
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
# Protect e-mail addresses from being logged only in production.
config.filter_parameters += [:email]
end
+30 -20
View File
@@ -1,3 +1,5 @@
require "active_support/core_ext/integer/time"
# The test environment is used exclusively to run your application's
# test suite. You never need to work with it otherwise. Remember that
# your test database is "scratch space" for the test suite and is wiped
@@ -6,49 +8,57 @@
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# While tests run files are not watched, reloading is not necessary.
config.enable_reloading = false
# Turn false under Spring and add config.action_view.cache_template_loading = true.
config.cache_classes = true
# Eager loading loads your entire application. When running a single test locally,
# this is usually not necessary, and can slow down your test suite. However, it's
# recommended that you enable it in continuous integration systems to ensure eager
# loading is working properly before deploying your code.
# Eager loading loads your whole application. When running a single test locally,
# this probably isn't necessary. It's a good idea to do in a continuous integration
# system, or in some way before deploying your code.
config.eager_load = ENV["CI"].present?
# Configure public file server for tests with cache-control for performance.
config.public_file_server.headers = { "cache-control" => "public, max-age=3600" }
# Configure public file server for tests with Cache-Control for performance.
config.public_file_server.enabled = true
config.public_file_server.headers = {
"Cache-Control" => "public, max-age=#{1.hour.to_i}"
}
# Behave as in `production`.
config.consider_all_requests_local = false
# Hide full error reports.
config.consider_all_requests_local = false
# Render exception templates instead of raising exceptions.
config.action_dispatch.show_exceptions = :all
# Disable caching.
config.action_controller.perform_caching = false
config.cache_store = :null_store
# Disable request forgery protection in test environment.
config.action_controller.allow_forgery_protection = false
config.action_mailer.perform_caching = false
# Tell Action Mailer not to deliver emails to the real world.
# The :test delivery method accumulates sent emails in the
# ActionMailer::Base.deliveries array.
config.action_mailer.delivery_method = :test
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = {host: Capybara.server_host,
protocol: 'http'}
config.action_mailer.default_url_options = {host: '127.0.0.1', :protocol => 'http'}
# Print deprecation notices to the stderr.
config.active_support.deprecation = :stderr
# Raise exceptions for disallowed deprecations.
config.active_support.disallowed_deprecation = :raise
# Tell Active Support which deprecation messages to disallow.
config.active_support.disallowed_deprecation_warnings = []
# Raises error for missing translations.
config.i18n.raise_on_missing_translations = true
# Annotate rendered view with file names.
# config.action_view.annotate_rendered_view_with_filenames = true
# Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true
# Allow Capybara application server IP
config.hosts |= [IPAddr.new(Capybara.server_host)]
config.log_level = :info
# Allow the default integration test host.
config.hosts << "www.example.com"
end
+5
View File
@@ -5,3 +5,8 @@ Rails.application.config.assets.version = "1.0"
# Add additional assets to the asset load path.
# Rails.application.config.assets.paths << Emoji.images_path
# Precompile additional assets.
# application.js, application.css, and all non-JS/CSS in the app/assets
# folder are already added.
Rails.application.config.assets.precompile += %w( '*.svg' )
@@ -16,13 +16,9 @@
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
# # Generate session nonces for permitted importmap and inline scripts
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src style-src)
#
# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`
# # if the corresponding directives are specified in `content_security_policy_nonce_directives`.
# # config.content_security_policy_nonce_auto = true
# config.content_security_policy_nonce_directives = %w(script-src)
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
+2 -6
View File
@@ -1,12 +1,8 @@
require 'core_ext/array_delete_bang'
require 'core_ext/float'
require 'core_ext/range'
require 'core_ext/big_decimal_scientific_notation'
ActiveSupport.on_load :action_dispatch_system_test_case do
prepend CoreExt::ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelperUniqueId
# Allow denormalized string comparison,
# https://github.com/orgs/teamcapybara/discussions/2841
XPath::DSL.prepend CoreExt::XPath::DSL::NormalizeSpaceDefault
end
ActiveSupport.on_load :action_view do
@@ -15,7 +11,7 @@ end
ActiveSupport.on_load :active_record do
ActiveModel::Validations::NumericalityValidator
.prepend CoreExt::ActiveModel::Validations::NumericalityValidatesPrecision
.prepend CoreExt::ActiveModel::Validations::NumericalityValidatesPrecisionAndScale
# Temporary patch for https://github.com/rails/rails/pull/54658
Arel::TreeManager::StatementMethods
+1 -1
View File
@@ -179,7 +179,7 @@ Devise.setup do |config|
# ==> Configuration for :validatable
# Range for password length.
config.password_length = 5..32
config.password_length = 5..128
# Email regex used to validate email formats. It simply asserts that
# one (and only one) @ exists in the given string. This is mainly
@@ -1,8 +1,8 @@
# Be sure to restart your server when you modify this file.
# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file.
# Use this to limit dissemination of sensitive information.
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
# Configure parameters to be filtered from the log file. Use this to limit dissemination of
# sensitive information. See the ActiveSupport::ParameterFilter documentation for supported
# notations and behaviors.
Rails.application.config.filter_parameters += [
:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
:passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
]
@@ -34,8 +34,4 @@ ActiveSupport.on_load :turbo_streams_tag_builder do
def unselect(target)
action :unselect, target, allow_inferred_rendering: false
end
def validate(target, content = nil, **rendering, &block)
action :validate, target, content, **rendering, &block
end
end
+9 -24
View File
@@ -1,11 +1,13 @@
en:
time:
formats:
default: "%Y-%m-%d %H:%M %Z"
without_tz: "%Y-%m-%d %H:%M"
# Format contains non-breaking space: 160.chr(Encoding::UTF_8)
default: "%Y-%m-%d %H:%M %Z"
without_tz: "%Y-%m-%d %H:%M"
errors:
messages:
out_of_range: exceeds range of double precision floating-point format
precision_exceeded: must not exceed %{value} significant digits
scale_exceeded: must not exceed %{value} decimal digits
activerecord:
attributes:
quantity:
@@ -41,24 +43,6 @@ en:
descendant_reference: cannot be changed to any of descendants
self_reference: of the quantitiy cannot be the quantity itself
user_mismatch: has to belong to the same user as quantity
activesupport:
# Based on: https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-misc-full/main/en/characters.json
characters:
subsets:
# :exemplarCharacters[EN]
exemplar: abcdefghijklmnopqrstuvwxyz
# :index[EN]
index: ABCDEFGHIJKLMNOPQRSTUVWXYZ
numbers: 0123456789
# :exemplarCharacters[DE, PL] - exemplarCharacters[EN] +
# :index[DE, PL] - :index[EN]
auxiliary: äößüĆŁÓŚŹŻąćęłńóśźż
# :punctuation* + :numbers* - space - numbers
punctuation: >-
!\"#%&'()*+,-./:;?@[]{|}~§«°»×‐‑–—‘’‚“”„†‡…‰′″−
# :exemplarCharacters[EL] + :index[EL]
symbols: αάβγδεέζηήθιίϊΐκλμνξοόπρσςτυύϋΰφχψωώΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ
space: ' '
actioncontroller:
exceptions:
status:
@@ -77,7 +61,6 @@ en:
helpers:
label:
user:
# TODO: remove or reduce all unnecessarily singly and doubly quoted text
password_confirmation: 'Retype new password:'
password_length_hint_html:
count: '%{minimum_password_length}'
@@ -102,10 +85,12 @@ en:
navigation: Measurements
no_items: There are no measurements taken. You can Add some now.
form:
select_quantity: select quantities...
taken_at_html: Measurement taken at&emsp;
select_quantity: select the measured quantities...
index:
new_measurement: Add measurement
readouts:
form:
select_unit: ...
quantities:
navigation: Quantities
no_items: There are no configured quantities. You can Add some or Import from defaults.
+36 -34
View File
@@ -1,41 +1,43 @@
# This configuration file will be evaluated by Puma. The top-level methods that
# are invoked here are part of Puma's configuration DSL. For more information
# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
# Puma can serve each request in a thread from an internal thread pool.
# The `threads` method setting takes two numbers: a minimum and maximum.
# Any libraries that use thread pools should be configured to match
# the maximum value specified for Puma. Default is set to 5 threads for minimum
# and maximum; this matches the default thread size of Active Record.
#
# Puma starts a configurable number of processes (workers) and each process
# serves each request in a thread from an internal thread pool.
max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
threads min_threads_count, max_threads_count
# Specifies the `worker_timeout` threshold that Puma will use to wait before
# terminating a worker in development environments.
#
# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
# should only set this value when you want to run 2 or more workers. The
# default is already 1.
#
# The ideal number of threads per worker depends both on how much time the
# application spends waiting for IO operations and on how much you wish to
# prioritize throughput over latency.
#
# As a rule of thumb, increasing the number of threads will increase how much
# traffic a given process can handle (throughput), but due to CRuby's
# Global VM Lock (GVL) it has diminishing returns and will degrade the
# response time (latency) of the application.
#
# The default is set to 3 threads as it's deemed a decent compromise between
# throughput and latency for the average Rails application.
#
# Any libraries that use a connection pool or another resource pool should
# be configured to provide at least as many connections as the number of
# threads. This includes Active Record's `pool` parameter in `database.yml`.
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count
worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development"
# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
port ENV.fetch("PORT", 3000), '127.0.0.1'
#
port ENV.fetch("PORT") { 3000 }
# Specifies the `environment` that Puma will run in.
#
environment ENV.fetch("RAILS_ENV") { "development" }
# Specifies the `pidfile` that Puma will use.
pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" }
# Specifies the number of `workers` to boot in clustered mode.
# Workers are forked web server processes. If using threads and workers together
# the concurrency of the application would be max `threads` * `workers`.
# Workers do not work on JRuby or Windows (both of which do not support
# processes).
#
# workers ENV.fetch("WEB_CONCURRENCY") { 2 }
# Use the `preload_app!` method when specifying a `workers` number.
# This directive tells Puma to first boot the application and load code
# before forking the application. This takes advantage of Copy On Write
# process behavior so workers use less memory.
#
# preload_app!
# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart
# Run the Solid Queue supervisor inside of Puma for single-server deployments
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
# Specify the PID file. Defaults to tmp/pids/server.pid in development.
# In other environments, only set the PID file if requested.
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
+1 -4
View File
@@ -15,10 +15,7 @@ Rails.application.routes.draw do
namespace :default do
resources :units, only: [:index, :destroy] do
member {
post :import
post :export
}
member { post :import, :export }
#collection { post :import_all }
end
end
+3 -3
View File
@@ -1,11 +1,11 @@
class CreateUsers < ActiveRecord::Migration[8.1]
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :email, null: false, limit: 64
t.integer :status, null: false, default: 0
t.timestamps
t.timestamps null: false
end
add_index :users, :email
add_index :users, :email, unique: true
end
end

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