5 Commits

135 changed files with 1169 additions and 3098 deletions

11
.gitignore vendored
View File

@@ -3,15 +3,10 @@
/.cache
/.gem
# Ignore service startup scripts.
/bin/fixinme.service
# Ignore:
# * master key for decrypting credentials and encrypted credentials,
# * custom app, database and server settings (based on *.dist templates).
# Ignore master key for decrypting credentials and more.
/config/application.rb
/config/credentials.yml.enc
/config/database.yml
/config/initializers/secret_token.rb
/config/master.key
/config/puma.rb
@@ -37,10 +32,8 @@
/.irb_history
/.lesshst
/.local
/.mysql_history
/.ssh
/.vim
/.viminfo
/.webdrivers
*.swp
Session.vim

1
.ruby-version Normal file
View File

@@ -0,0 +1 @@
ruby-3.3.0

View File

@@ -1,34 +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,
* 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 all 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

31
Gemfile
View File

@@ -1,30 +1,20 @@
source "https://rubygems.org"
ruby file: ".ruby-version"
# The requirement for the Ruby version comes from Rails
gem "rails", "~> 7.2.3"
gem "rails", "~> 7.1.2"
gem "sprockets-rails"
gem "mysql2", "~> 0.5"
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
# 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
gem "mysql2", "~> 0.5"
end
group :postgresql, optional: true do
gem "pg", "~> 1.5"
end
group :sqlite, optional: true do
gem "sqlite3", "~> 2.7"
end
gem "devise"
gem "importmap-rails"
gem "turbo-rails", "~> 2.0"
gem 'importmap-rails'
# turborails >= 2.0.0 required with npm v8.0.0 with support for [autofocus]
# attribute in turbo-streams
gem 'turbo-rails', '> 1.5.0'
group :development, :test do
gem "byebug"
@@ -42,11 +32,4 @@ group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara"
gem "selenium-webdriver"
# Remove minitest version restriction after error fixed:
# railties-7.2.3/lib/rails/test_unit/line_filtering.rb:7:in `run':
# wrong number of arguments (given 3, expected 1..2) (ArgumentError)
# from /var/www/.gem/ruby/3.3.0/gems/minitest-6.0.2/lib/minitest.rb:473:in
# `block (2 levels) in run_suite'
gem "minitest", "< 6"
end

View File

@@ -1,247 +1,205 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (7.2.3)
actionpack (= 7.2.3)
activesupport (= 7.2.3)
actioncable (7.1.3)
actionpack (= 7.1.3)
activesupport (= 7.1.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
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 (7.2.3)
actionpack (= 7.2.3)
actionview (= 7.2.3)
activejob (= 7.2.3)
activesupport (= 7.2.3)
mail (>= 2.8.0)
actionmailbox (7.1.3)
actionpack (= 7.1.3)
activejob (= 7.1.3)
activerecord (= 7.1.3)
activestorage (= 7.1.3)
activesupport (= 7.1.3)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.1.3)
actionpack (= 7.1.3)
actionview (= 7.1.3)
activejob (= 7.1.3)
activesupport (= 7.1.3)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.2)
actionpack (7.2.3)
actionview (= 7.2.3)
activesupport (= 7.2.3)
cgi
actionpack (7.1.3)
actionview (= 7.1.3)
activesupport (= 7.1.3)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4, < 3.3)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (7.2.3)
actionpack (= 7.2.3)
activerecord (= 7.2.3)
activestorage (= 7.2.3)
activesupport (= 7.2.3)
actiontext (7.1.3)
actionpack (= 7.1.3)
activerecord (= 7.1.3)
activestorage (= 7.1.3)
activesupport (= 7.1.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.2.3)
activesupport (= 7.2.3)
actionview (7.1.3)
activesupport (= 7.1.3)
builder (~> 3.1)
cgi
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.2.3)
activesupport (= 7.2.3)
activejob (7.1.3)
activesupport (= 7.1.3)
globalid (>= 0.3.6)
activemodel (7.2.3)
activesupport (= 7.2.3)
activerecord (7.2.3)
activemodel (= 7.2.3)
activesupport (= 7.2.3)
activemodel (7.1.3)
activesupport (= 7.1.3)
activerecord (7.1.3)
activemodel (= 7.1.3)
activesupport (= 7.1.3)
timeout (>= 0.4.0)
activestorage (7.2.3)
actionpack (= 7.2.3)
activejob (= 7.2.3)
activerecord (= 7.2.3)
activesupport (= 7.2.3)
activestorage (7.1.3)
actionpack (= 7.1.3)
activejob (= 7.1.3)
activerecord (= 7.1.3)
activesupport (= 7.1.3)
marcel (~> 1.0)
activesupport (7.2.3)
activesupport (7.1.3)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
base64 (0.3.0)
bcrypt (3.1.21)
benchmark (0.5.0)
bigdecimal (4.0.1)
mutex_m
tzinfo (~> 2.0)
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
base64 (0.2.0)
bcrypt (3.1.20)
bigdecimal (3.1.6)
bindex (0.8.1)
builder (3.3.0)
byebug (13.0.0)
reline (>= 0.6.0)
capybara (3.40.0)
builder (3.2.4)
byebug (11.1.3)
capybara (3.39.2)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.11)
nokogiri (~> 1.8)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
cgi (0.5.1)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
concurrent-ruby (1.2.3)
connection_pool (2.4.1)
crass (1.0.6)
date (3.5.1)
devise (5.0.2)
date (3.3.4)
devise (4.9.3)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 7.0)
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
drb (2.2.3)
erb (6.0.2)
erubi (1.13.1)
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)
drb (2.2.0)
ruby2_keywords
erubi (1.12.0)
ffi (1.16.3)
globalid (1.2.1)
activesupport (>= 6.1)
i18n (1.14.8)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
importmap-rails (2.2.3)
importmap-rails (2.0.1)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.2)
irb (1.17.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
io-console (0.7.2)
irb (1.11.1)
rdoc
reline (>= 0.4.2)
logger (1.7.0)
loofah (2.25.0)
loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
logger
mail (2.8.1)
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.1.0)
matrix (0.4.3)
marcel (1.0.2)
matrix (0.4.2)
mini_mime (1.1.5)
minitest (5.27.0)
mysql2 (0.5.7)
bigdecimal
net-imap (0.6.3)
minitest (5.21.2)
mutex_m (0.2.0)
mysql2 (0.5.5)
net-imap (0.4.9.1)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.5.1)
net-smtp (0.4.0.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.1-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.1-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.1-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.1-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-musl)
nio4r (2.7.0)
nokogiri (1.16.0-x86_64-linux)
racc (~> 1.4)
orm_adapter (0.5.0)
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)
psych (5.3.1)
date
psych (5.1.2)
stringio
public_suffix (7.0.2)
puma (6.6.1)
public_suffix (5.0.4)
puma (6.4.2)
nio4r (~> 2.0)
racc (1.8.1)
rack (3.2.5)
rack-session (2.1.1)
base64 (>= 0.1.0)
racc (1.7.3)
rack (3.0.8)
rack-session (2.0.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack-test (2.1.0)
rack (>= 1.3)
rackup (2.3.1)
rackup (2.1.0)
rack (>= 3)
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)
webrick (~> 1.8)
rails (7.1.3)
actioncable (= 7.1.3)
actionmailbox (= 7.1.3)
actionmailer (= 7.1.3)
actionpack (= 7.1.3)
actiontext (= 7.1.3)
actionview (= 7.1.3)
activejob (= 7.1.3)
activemodel (= 7.1.3)
activerecord (= 7.1.3)
activestorage (= 7.1.3)
activesupport (= 7.1.3)
bundler (>= 1.15.0)
railties (= 7.2.3)
rails-dom-testing (2.3.0)
railties (= 7.1.3)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
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 (7.2.3)
actionpack (= 7.2.3)
activesupport (= 7.2.3)
cgi
irb (~> 1.13)
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.1.3)
actionpack (= 7.1.3)
activesupport (= 7.1.3)
irb
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rake (13.3.1)
rdoc (7.2.0)
erb
rake (13.1.0)
rdoc (6.6.2)
psych (>= 4.0.0)
tsort
regexp_parser (2.11.3)
reline (0.6.3)
regexp_parser (2.9.0)
reline (0.4.2)
io-console (~> 0.5)
responders (3.2.0)
actionpack (>= 7.0)
railties (>= 7.0)
rexml (3.4.4)
rubyzip (3.2.2)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
rexml (3.2.6)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
sassc (2.4.0)
ffi (~> 1.9)
sassc-rails (2.1.2)
@@ -250,40 +208,27 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
securerandom (0.4.1)
selenium-webdriver (4.41.0)
base64 (~> 0.2)
logger (~> 1.4)
selenium-webdriver (4.16.0)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sprockets (4.2.2)
sprockets (4.2.1)
concurrent-ruby (~> 1.0)
logger
rack (>= 2.2.4, < 4)
sprockets-rails (3.5.2)
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets-rails (3.4.2)
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets (>= 3.0.0)
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.7.0)
timeout (0.6.0)
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
stringio (3.1.0)
thor (1.3.0)
tilt (2.3.0)
timeout (0.4.1)
turbo-rails (2.0.0.pre.beta.3)
actionpack (>= 6.0.0)
activejob (>= 6.0.0)
railties (>= 6.0.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.1)
@@ -291,42 +236,32 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
websocket (1.2.11)
websocket-driver (0.8.0)
base64
webrick (1.8.1)
websocket (1.2.10)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.5)
zeitwerk (2.6.12)
PLATFORMS
aarch64-linux-gnu
aarch64-linux-musl
arm-linux-gnu
arm-linux-musl
arm64-darwin
x86_64-darwin
x86_64-linux-gnu
x86_64-linux-musl
x86_64-linux
DEPENDENCIES
byebug
capybara
devise
importmap-rails
minitest (< 6)
mysql2 (~> 0.5)
pg (~> 1.5)
puma (~> 6.0)
rails (~> 7.2.3)
rails (~> 7.1.2)
sassc-rails
selenium-webdriver
sprockets-rails
sqlite3 (~> 2.7)
turbo-rails (~> 2.0)
turbo-rails (> 1.5.0)
tzinfo-data
web-console
BUNDLED WITH
2.6.3
2.5.3

106
README.md
View File

@@ -4,74 +4,44 @@ README
Quantified self
Installation
------------
The steps described in this section are for preparing a production installation.
For possible modifications to this procedure to configure the development
environment, see the _Contributing_ section below.
### Requirements
Software requirements
---------------------
* Server side:
* Ruby interpreter, depending on the version of Rails used (see _Gemfile_),
* https://guides.rubyonrails.org/upgrading_ruby_on_rails.html#ruby-versions
* database (e.g. MySQL >= 8.0) supporting:
* 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
* Ruby version: developed on Ruby 3.x
* database with recursive Common Table Expressions (CTE) support, e.g.
MySQL >= 8.0, MariaDB >= 10.2.2
* for testing: browser as specified in _Client side_ requirements
* Client side:
* browser (e.g. Firefox >= 121) supporting:
* browser supporting below requirements (e.g. Firefox >= 121):
* [`import maps`](https://caniuse.com/import-maps)
(required by `importmap-rails` gem >= 2.0)
* CSS [`:has()` pseudo-class](https://caniuse.com/css-has)
### Gems
On systems where development tools and libraries are not installed by default
(such as Ubuntu), you should install them before proceeding with Ruby gems.
Select the database client library according to the database engine you are
planning to use:
sudo apt install build-essential libyaml-dev libmysqlclient-dev
Installation
------------
git clone https://gitea.michalczyk.pro/fixin.me/fixin.me.git
cd fixin.me
bundle config --local frozen true
bundle config --local path .gem
Select which database engine gem to install (mysql, postgresql, sqlite):
bundle config --local with mysql
bundle config set --local path '.gem'
bundle install
### Configuration
Customize application settings (starting below `SETUP` comment) appropriately:
Configuration
-------------
cp -a config/application.rb.dist config/application.rb
Create `secret_key_base`. It will be automatically generated on first
`credentials:edit`, so it's enough to run command below, save the file and exit
editor:
Modify configuration settings below `SETUP` comment appropriately.
bundle exec rails credentials:edit
Precompile assets:
RAILS_ENV=production bundle exec rails assets:precompile
### Database
Database
--------
Grant database user and privileges:
> mysql -p
mysql> create user fixinme@localhost identified by 'Some-password1%';
mysql> create user fixinme@localhost identified by '<some password>';
mysql> grant all privileges on fixinme.* to fixinme@localhost;
mysql> flush privileges;
@@ -81,7 +51,7 @@ Copy config template and update database configuration:
Run database creation and migration tasks:
RAILS_ENV=production bundle exec rails db:create db:migrate db:seed
RAILS_ENV="production" bundle exec rake db:create db:migrate db:seed
Running
@@ -89,7 +59,7 @@ Running
### Standalone Rails server + Apache proxy
Customize Puma config template:
Copy Puma config template:
cp -a config/puma.rb.dist config/puma.rb
@@ -97,21 +67,10 @@ and specify server IP/port, either with `port` or `bind`, e.g.:
bind 'tcp://0.0.0.0:3000'
#### (option 1) Start server manually
Run server
bundle exec rails s -e production
#### (option 2) Start server as systemd service
Customize service template, setting at least `User` and `WorkingDirectory`:
sudo cp bin/fixinme.service.dist /etc/systemd/system/fixinme.service
sudo systemctl daemon-reload
sudo systemctl enable fixin.service
sudo systemctl start fixin.service
sudo systemctl status fixin.service
### Apache mod_passenger
@@ -121,21 +80,6 @@ TODO: add sample configuration
Contributing
------------
### Gems
Apart from database adapter, install development and testing gems:
bundle config --local with mysql development test
### Configuration
If you have previously precomiled assets for production environment, you should
clean them for development. Otherwise, if precompiled assets are available,
they will be served - even if they no longer match the original (uncompiled)
assets.
bundle exec rails assets:clean
### Database
Grant database user privileges for development and test environments,
@@ -146,6 +90,7 @@ possibly with different Ruby versions:
mysql> grant all privileges on `fixinme-%`.* to `fixinme-dev`@localhost;
mysql> flush privileges;
### Development environment
Starting application server in development environment:
@@ -154,7 +99,8 @@ Starting application server in development environment:
For running rake tasks, prepend command with environment:
RAILS_ENV=development bundle exec rails ...
RAILS_ENV="development" bundle exec rake ...
### Running tests
@@ -172,13 +118,3 @@ Tests need to be run from within toplevel application directory:
bundle exec rails test test/system/users_test.rb --seed 1234
### Icons
Pictogrammers Material Design Icons: https://pictogrammers.com/library/mdi/
### Rake tasks
Exporting default settings defined in application to seed file (e.g. to send as
PR or share between installations):
bundle exec rails db:seed:export

View File

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

Before

Width:  |  Height:  |  Size: 152 B

After

Width:  |  Height:  |  Size: 167 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M12,2L16,6H13V13.85L19.53,17.61L21,15.03L22.5,20.5L17,21.96L18.53,19.35L12,15.58L5.47,19.35L7,21.96L1.5,20.5L3,15.03L4.47,17.61L11,13.85V6H8L12,2Z" /></svg>

Before

Width:  |  Height:  |  Size: 236 B

View File

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

Before

Width:  |  Height:  |  Size: 278 B

After

Width:  |  Height:  |  Size: 293 B

View File

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

Before

Width:  |  Height:  |  Size: 148 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M3,16.74L7.76,12L3,7.26L7.26,3L12,7.76L16.74,3L21,7.26L16.24,12L21,16.74L16.74,21L12,16.24L7.26,21L3,16.74M12,13.41L16.74,18.16L18.16,16.74L13.41,12L18.16,7.26L16.74,5.84L12,10.59L7.26,5.84L5.84,7.26L10.59,12L5.84,16.74L7.26,18.16L12,13.41Z" /></svg>

Before

Width:  |  Height:  |  Size: 330 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M12 14L19 7H15V1H9V7H5L12 14M12 11.17L9.83 9H11V3H13V9H14.17L12 11.17M5 16V18H19V16H5M5 22V20H19V22H5Z" /></svg>

Before

Width:  |  Height:  |  Size: 192 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M13,5V11H14.17L12,13.17L9.83,11H11V5H13M15,3H9V9H5L12,16L19,9H15V3M19,18H5V20H19V18Z" /></svg>

Before

Width:  |  Height:  |  Size: 174 B

View File

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

Before

Width:  |  Height:  |  Size: 316 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M13,7H11V11H7V13H11V17H13V13H17V11H13V7Z" /></svg>

Before

Width:  |  Height:  |  Size: 297 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M6,11H11V6H17V11H22V17H17V22H11V17H6V11M13,15V20H15V15H20V13H15V8H13V13H8V15H13M2,13V7H7V2H13V4H9V9H4V13H2Z" /></svg>

Before

Width:  |  Height:  |  Size: 197 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M5,2H19A2,2 0 0,1 21,4V20A2,2 0 0,1 19,22H5A2,2 0 0,1 3,20V4A2,2 0 0,1 5,2M12,4A4,4 0 0,0 8,8H11.26L10.85,5.23L12.9,8H16A4,4 0 0,0 12,4M5,10V20H19V10H5Z" /></svg>

Before

Width:  |  Height:  |  Size: 242 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M21,10.12H14.22L16.96,7.3C14.23,4.6 9.81,4.5 7.08,7.2C4.35,9.91 4.35,14.28 7.08,17C9.81,19.7 14.23,19.7 16.96,17C18.32,15.65 19,14.08 19,12.1H21C21,14.08 20.12,16.65 18.36,18.39C14.85,21.87 9.15,21.87 5.64,18.39C2.14,14.92 2.11,9.28 5.62,5.81C9.13,2.34 14.76,2.34 18.27,5.81L21,3V10.12M12.5,8V12.25L16,14.33L15.28,15.54L11,13V8H12.5Z" /></svg>

Before

Width:  |  Height:  |  Size: 423 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M9,10V16H15V10H19L12,3L5,10H9M12,5.8L14.2,8H13V14H11V8H9.8L12,5.8M19,18H5V20H19V18Z" /></svg>

Before

Width:  |  Height:  |  Size: 173 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M19.95,10.56C19.75,9.67 18.95,9 18,9H15.46C15.81,8.41 16,7.73 16,7A4,4 0 0,0 12,3A4,4 0 0,0 8,7C8,7.73 8.19,8.41 8.54,9H6C5.05,9 4.25,9.67 4.05,10.56C2.04,18.57 2,18.78 2,19A2,2 0 0,0 4,21H20A2,2 0 0,0 22,19C22,18.78 21.96,18.57 19.95,10.56M12,5A2,2 0 0,1 14,7A2,2 0 0,1 12,9A2,2 0 0,1 10,7A2,2 0 0,1 12,5M15,13H11V17H13V14H15V19H11C9.89,19 9,18.11 9,17V13C9,11.89 9.89,11 11,11H15V13Z" /></svg>

Before

Width:  |  Height:  |  Size: 475 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M12,3A4,4 0 0,1 16,7C16,7.73 15.81,8.41 15.46,9H18C18.95,9 19.75,9.67 19.95,10.56C21.96,18.57 22,18.78 22,19A2,2 0 0,1 20,21H4A2,2 0 0,1 2,19C2,18.78 2.04,18.57 4.05,10.56C4.25,9.67 5.05,9 6,9H8.54C8.19,8.41 8,7.73 8,7A4,4 0 0,1 12,3M12,5A2,2 0 0,0 10,7A2,2 0 0,0 12,9A2,2 0 0,0 14,7A2,2 0 0,0 12,5M6,11V19H8V16.5L9,17.5V19H11V17L9,15L11,13V11H9V12.5L8,13.5V11H6M15,11C13.89,11 13,11.89 13,13V17C13,18.11 13.89,19 15,19H18V14H16V17H15V13H18V11H15Z" /></svg>

After

Width:  |  Height:  |  Size: 537 B

View File

@@ -12,18 +12,17 @@
* file should be added after the last require_* statement. It is generally
* better to create a new file per style scope.
*
*= require_tree .
*= require_self
*/
/* Strive for simplicity:
* * style elements/tags only - if possible,
* * replace element/tag name with class name - if element has to be styled
* differently depending on context (e.g. <form>, <table>, <a> as link/button),
* * styles with multiple selectors should have all selectors with same
* specificity, to allow proper rule specificity vs order management.
*
* NOTE: style in a modular way, similar to how CSS @scope would be used,
* to make transition easier once @scope is widely available. */
/* Colors:
* dark blue: #006c9b
* light blue: #009ade
* dark red: #b21237
* light red: #ff1f5b
* */
:root {
--color-focus-gray: #f3f3f3;
--color-border-gray: #dddddd;
@@ -32,13 +31,6 @@
--color-table-gray: #909090;
--color-text-gray: #707070;
--color-dark-blue: #006c9b;
--color-blue: #009ade;
--color-dark-red: #b21237;
--color-red: #ff1f5b;
--depth: 0;
--z-index-flashes: 100;
--z-index-table-row-outline: 10;
}
@@ -49,169 +41,48 @@
box-sizing: border-box;
}
::selection {
background-color: var(--color-blue);
background-color: #009ade;
color: white;
}
:focus-visible {
outline: none;
}
/* NOTE: move to higher priority layer instead of using !important?; add CSS
* @layer requirements in README */
[disabled] {
border-color: var(--color-border-gray) !important;
color: var(--color-border-gray) !important;
/* NOTE: cannot set cursor when `pointer-events: none`; can be fixed by setting
* `cursor` on wrapping element.
cursor: not-allowed; */
fill: var(--color-border-gray) !important;
pointer-events: none !important;
}
/* Styles set `display` without distinguishing between [hidden] elements, making
* them visible. */
[hidden] {
display: none !important;
}
/* Color coding of input controls' background:
* blue - target for interaction with pointer,
* gray - target for interaction with keyboard,
* red - destructive, non-undoable action.
*/
/* TODO: merge selectors using :is() */
a,
body {
display: grid;
gap: 0.8em;
grid-template-areas:
"header header header"
"nav nav nav"
"leftside main rightside";
grid-template-columns: 1fr auto 1fr;
grid-template-rows: repeat(3, auto);
font-family: system-ui;
margin: 0.4em;
}
button,
details,
input,
select,
summary,
textarea {
background-color: inherit;
font: inherit;
}
details,
input,
select {
text-align: inherit;
}
input,
select,
summary,
textarea {
border: 1px solid var(--color-gray);
border-radius: 0.25em;
padding: 0.2em 0.4em;
}
svg {
height: 1.4em;
margin: 0 0.2em 0 0;
width: 1.4em;
}
svg:last-child {
margin-right: 0;
}
textarea {
margin: 0;
}
input[type=checkbox] {
accent-color: var(--color-blue);
appearance: none;
-webkit-appearance: none;
display: flex;
height: 1.1em;
margin: 0;
padding: 0;
width: 1.1em;
}
input[type=checkbox]:checked {
appearance: checkbox;
-webkit-appearance: checkbox;
}
/* Hide spin buttons of <input type=number>. */
/* TODO: add spin buttons inside <input type=number>: before (-) and after (+) input. */
input[type=number] {
appearance: textfield;
-moz-appearance: textfield;
text-align: end;
}
input::-webkit-inner-spin-button {
-webkit-appearance: none;
}
input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Text color of table form controls:
* - black for row/table forms,
* - inherited for internal (column specific) buttons/forms. */
table input,
table select,
table summary,
table textarea {
border-color: var(--color-border-gray);
}
table input,
table select,
table textarea {
padding-block: 0.375em;
}
table form input,
table form select,
table form summary,
table form textarea {
color: inherit;
}
table svg:not(:only-child) {
height: 1.25em;
width: 1.25em;
}
input:focus-visible,
select:focus-visible,
select:focus-within,
/* TODO: how to achieve `summary:focus-within` for `::details-content`? */
summary:focus-visible,
textarea:focus-visible {
accent-color: var(--color-dark-blue);
background-color: var(--color-focus-gray);
color: black;
}
input:hover,
select:hover,
summary:hover,
textarea:hover {
border-color: var(--color-blue);
outline: 1px solid var(--color-blue);
}
select:hover,
summary:hover {
color: black;
cursor: pointer;
}
/* TODO: style <details>/<summary> focus to match <select> as much as possible.
summary:focus-visible::before,
summary:hover::before {
background-color: black;
}
*/
input:invalid,
select:invalid,
textarea:invalid {
border-color: var(--color-red);
outline-color: var(--color-red);
}
/* `.button`: button-styled <a>, <button>, <input type=submit>.
* `.link`: any other <a>.
* `.tab`: tab-styled <a>.
*/
.button,
.link,
.tab {
/* blue - target for interaction with pointer */
/* gray - target for interaction with keyboard */
/* TODO: remove non-font-size rems from buttons/inputs below */
a,
button,
input[type=submit] {
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.button,
button,
input[type=submit],
.tab {
align-items: center;
color: var(--color-gray);
@@ -219,81 +90,94 @@ textarea:invalid {
fill: var(--color-gray);
font-weight: bold;
}
.button {
border: 1px solid var(--color-gray);
border-radius: 0.25em;
.button,
button,
input[type=submit] {
font-size: 0.8rem;
padding: 0.6em 0.5em;
padding: 0.4em;
width: fit-content;
}
.link {
color: inherit;
text-decoration: underline 1px var(--color-border-gray);
text-underline-offset: 0.25em;
input:not([type=submit]):not([type=checkbox]),
select {
padding: 0.2em 0.4em;
}
[name=cancel],
.auxiliary {
border-color: var(--color-border-gray);
color: var(--color-nav-gray);
fill: var(--color-nav-gray);
.button,
button,
input,
select {
border: solid 1px var(--color-gray);
border-radius: 0.25em;
}
.button > svg,
.tab > svg,
button > svg {
height: 1.8em;
padding-right: 0.4em;
width: 1.8em;
}
/* 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,
.tab:focus-visible,
.tab:hover {
button:focus-visible,
input[type=submit]:focus-visible {
background-color: var(--color-focus-gray);
}
.button:hover {
background-color: var(--color-blue);
border-color: var(--color-blue);
.button:hover,
button:hover,
input[type=submit]:hover {
background-color: #009ade;
border-color: #009ade;
color: white;
fill: white;
}
.dangerous:hover {
background-color: var(--color-red);
border-color: var(--color-red);
background-color: #ff1f5b;
border-color: #ff1f5b;
}
.link:focus-visible {
text-decoration-color: var(--color-gray);
input[type=checkbox] {
accent-color: #009ade;
appearance: none;
-webkit-appearance: none;
display: flex;
height: 1.1rem;
margin: 0;
width: 1.1rem;
}
.link:hover {
color: var(--color-blue);
text-decoration-color: var(--color-blue);
input[type=checkbox]:checked {
appearance: checkbox;
-webkit-appearance: checkbox;
}
table .button {
border-color: var(--color-border-gray);
color: var(--color-table-gray);
font-weight: normal;
height: 100%;
padding: 0.4em;
input:hover,
select:hover {
border-color: #009ade;
outline: solid 1px #009ade;
}
select:hover {
cursor: pointer;
}
input:focus-visible,
select:focus-within,
select:focus-visible {
accent-color: #006c9b;
background-color: var(--color-focus-gray);
}
input[type=text]:read-only {
border: none;
padding-left: 0;
padding-right: 0;
}
/* NOTE: collapse gaps around empty rows (`topside`) once possible with
* `grid-collapse` property and remove alternative `grid-template-areas`.
* https://github.com/w3c/csswg-drafts/issues/5813 */
body {
display: grid;
.header {
display: flex;
gap: 0.8em;
grid-template-areas:
"header header header"
"nav nav nav"
"leftside main rightside";
grid-template-columns: 1fr minmax(max-content, 2fr) 1fr;
font-family: system-ui;
margin: 0.4em;
}
body:has(> .topside-area) {
grid-template-areas:
"header header header"
"nav nav nav"
"leftside topside rightside"
"leftside main rightside";
}
header {
grid-area: header;
}
.navigation {
display: flex;
grid-area: nav;
@@ -302,46 +186,36 @@ 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;
padding-block: 0.3em;
}
.navigation > .tab:hover,
.navigation > .tab:focus-visible {
background-color: var(--color-focus-gray);
}
.navigation > .tab.active {
border-bottom: 4px solid var(--color-blue);
color: var(--color-blue);
fill: var(--color-blue);
border-bottom: solid 4px #009ade;
color: #009ade;
fill: #009ade;
}
.topside-area {
grid-area: topside;
}
.leftside-area {
.leftside {
grid-area: leftside;
}
.main-area {
.main {
grid-area: main;
}
.rightside-area {
.rightside {
grid-area: rightside;
}
.buttongrid {
display: grid;
gap: 0.4em;
grid-template-areas: "context empty tools";
grid-template-columns: auto 1fr auto;
grid-template-rows: max-content;
}
.tools-area {
grid-area: tools;
}
#flashes {
display: grid;
row-gap: 0.4em;
gap: 0.2em;
grid-template-columns: 1fr auto auto auto 1fr;
left: 0;
pointer-events: none;
@@ -357,286 +231,256 @@ header {
display: grid;
grid-column: 2/5;
grid-template-columns: subgrid;
line-height: 2.2em;
pointer-events: auto;
}
.flash:before {
filter: invert();
.flash.alert:before {
content: url('pictograms/alert-outline.svg');
height: 1.4em;
margin: 0 0.5em;
width: 1.4em;
}
.flash.alert:before {
content: url('pictograms/alert-outline.svg');
}
.flash.alert {
border-color: var(--color-red);
background-color: var(--color-red);
border-color: #ff1f5b;
background-color: #ff1f5b;
}
.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);
border-color: #009ade;
background-color: #009ade;
}
.flash svg {
.flash > div {
grid-column: 2;
}
/* NOTE: currently flash button inherits some unnecessary styles from generic
* button. */
.flash > button {
border: none;
color: inherit;
cursor: pointer;
fill: white;
height: 2.2em;
font-size: 1.4em;
font-weight: bold;
grid-column: 3;
opacity: 0.6;
padding: 0.4em 0.5em;
width: 2.4em;
padding: 0.2em 0.4em;
}
.flash svg:hover {
.flash > button:hover {
opacity: 1;
}
.labeled-form {
align-items: center;
display: grid;
gap: 0.9em 1.1em;
grid-template-columns: 1fr minmax(max-content, 0.5fr) 1fr;
/* TODO: Update styling, including rem removal. */
form table {
border-spacing: 0.8rem;
}
.labeled-form label {
form tr td:first-child {
color: var(--color-gray);
font-size: 0.9rem;
grid-column: 1;
padding-right: 0.25rem;
text-align: right;
white-space: nowrap;
}
.labeled-form label.required {
form label.required {
font-weight: bold;
}
/* Don't style `label.error + input` if case already covered by `input:invalid`. */
.labeled-form label.error {
color: var(--color-red);
form label.error,
form td.error::after {
color: #ff1f5b;
}
.labeled-form em {
form td.error {
display: -webkit-box;
}
form td.error::after {
content: attr(data-content);
font-size: 0.9rem;
margin-left: 1rem;
padding: 0.25rem 0;
position: absolute;
}
form em {
color: var(--color-text-gray);
font-size: 0.75rem;
font-weight: normal;
}
.labeled-form input {
grid-column: 2;
}
.labeled-form input[type=submit] {
form input[type=submit] {
float: none;
font-size: 1rem;
margin: 1em auto 0 auto;
padding: 0.75em;
}
.labeled-form .auxiliary {
grid-column: 3;
/* If more buttons are needed, `grid-row` can be replaced with
* `reading-flow: grid-columns` to ensure proper [tabindex] order. */
grid-row: 1;
height: 100%;
padding-block: 0;
}
.tabular-form table {
border: none;
border-spacing: 0.4em 0;
margin-inline: -0.4em;
}
.tabular-form table td {
border: none;
vertical-align: middle;
}
.tabular-form table td {
padding-inline: 0;
}
.tabular-form table :is(form, input, select, textarea):only-child {
margin-inline-start: 0;
margin: 1.5rem auto 0 auto;
padding: 0.75rem;
}
.items-table {
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.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 {
table.items th,
table.items td {
padding-inline: 1em 0;
}
table.items td:has(input) {
padding-inline-start: calc(0.6em - 0.9px);
}
table.items th:last-child {
padding-inline-end: 0.4em;
}
.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 {
table.items 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);
table.items td {
border-top: solid 1px var(--color-border-gray);
min-height: 2.4em;
padding-block: 0.1em;
}
/* 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 0 0 1em;
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.subunit {
padding-inline-start: 1.8em;
}
table.items td.subunit:has(input) {
padding-inline-start: calc(1.4em - 1px);
}
table.items td.actions {
align-items: center;
display: flex;
gap: 0.4em;
justify-content: end;
}
.items-table .dropzone {
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 #009ade;
outline-offset: -1px;
z-index: var(--z-index-table-row-outline);
}
.items-table .handle {
cursor: grab;
}
.items-table .form td {
vertical-align: top;
}
.items-table td:not(:first-child),
.grayed {
color: var(--color-table-gray);
fill: var(--color-gray);
}
.items-table td:has(> svg:only-child) {
text-align: center;
table.items td.handle {
cursor: move;
}
/* TODO: replace :hover:focus-visible combos with proper LOVE stye order */
/* TODO: Update styling, including rem removal. */
table.items td.link a:hover,
table.items td.link a:focus-visible,
table.items td.link a:hover:focus-visible {
text-decoration: underline;
text-decoration-thickness: 0.05rem;
text-underline-offset: 0.2rem;
}
table.items td.link a:hover {
color: #009ade;
}
table.items td.link a:focus-visible {
text-decoration-color: var(--color-gray);
}
table.items td.link a:hover:focus-visible {
color: #006c9b;
}
.center {
margin: 0 auto;
table.items td:not(:first-child) {
color: var(--color-table-gray);
fill: var(--color-table-gray);
}
.hexpand {
width: 100%;
}
.flex {
display: flex;
gap: 0.8em;
}
.flex.reverse {
flex-direction: row-reverse;
}
.flex.vertical {
flex-direction: column;
}
.hint {
table.items td.hint {
color: var(--color-table-gray);
font-style: italic;
font-size: 0.9rem;
text-align: center;
font-size: 0.8rem;
padding: 1em;
}
.hmin50 {
min-width: 50%;
table.items svg {
height: 1.2rem;
vertical-align: middle;
width: 1.2rem;
}
.italic {
color: var(--color-gray);
font-style: italic;
}
.ralign {
table.items td.number {
text-align: right;
}
.rextend {
table.items .button,
table.items button,
table.items input[type=submit] {
font-weight: normal;
padding: 0.3em;
}
/* 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) {
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;
}
.centered {
margin: 0 auto;
}
.extendedright {
margin-right: auto;
}
details {
align-content: center;
position: relative;
.extendedleft {
margin-left: auto;
}
summary {
align-items: center;
color: var(--color-gray);
display: flex;
gap: 0.4em;
height: 100%;
[disabled] {
border-color: var(--color-border-gray) !important;
color: var(--color-border-gray) !important;
cursor: not-allowed;
fill: var(--color-border-gray) !important;
pointer-events: none;
}
.unwrappable {
white-space: nowrap;
}
summary::before {
background-color: currentColor;
content: "";
height: 1em;
mask-image: url('pictograms/chevron-down.svg');
mask-size: cover;
width: 1em;
}
summary:has(.button) {
padding-block: 0;
padding-inline-end: 0;
}
summary .button {
border: 1px solid var(--color-border-gray);
border-radius: inherit;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-style: none none none solid;
height: 100%;
}
summary span {
width: 100%;
}
details[open] summary::before {
transform: scaleY(-1);
}
summary::marker {
padding-left: 0.25em;
}
/* NOTE: use `details[open]::details-content` once widely available. */
details[open] ul {
background-color: white;
border: 1px solid var(--color-border-gray);
border-radius: 0.25em;
box-shadow: 1px 1px 3px var(--color-border-gray);
margin: -1px 0 0 0;
padding-left: 0;
position: absolute;
width: 100%;
}
li:hover {
background-color: var(--color-focus-gray);
}
li label {
align-items: center;
display: flex;
line-height: 1.4em;
}
li input[type=checkbox] {
margin: 0 0.25em;
}
li::marker {
content: '';
}
/*
* TODO:
* * disable <label> containing disabled checkbox: `label:has(input[disabled])`,
* * disabled label styling,
* * focused label styling (currently only checkbox has focus),
* * disabled checkbox blue square focus removal.
* */
#measurement_form {
min-width: 66%;
width: max-content;
.buttongrid {
display: grid;
gap: 0.4em;
grid-template-areas: "context empty tools";
grid-template-columns: auto 1fr auto;
grid-template-rows: max-content;
}
.tools {
grid-area: tools;
}

View File

@@ -1,4 +0,0 @@
/*
*= require application
*= require_self
*/

View File

@@ -25,24 +25,24 @@ class ApplicationController < ActionController::Base
# Turbo will reload 2nd time with HTML format and flashes will be lost.
rescue_from *ActionDispatch::ExceptionWrapper.rescue_responses.keys, with: :rescue_turbo
# Required by #respond_with (gem `responders`) used by Devise controllers.
respond_to :html, :turbo_stream
def after_sign_in_path_for(resource)
# TODO: allow setting path per-user or save last path in session and restore
units_path
end
def after_sign_out_path_for(resource)
new_user_session_path
end
protected
def current_user_disguised?
session[:revert_to_id].present?
end
def after_sign_in_path_for(scope)
if current_user.at_least(:admin)
users_path
else
edit_user_registration_path
end
end
def after_sign_out_path_for(scope)
new_user_session_path
end
class << self
attr_reader :navigation_menu_tab
def navigation_tab(name)
@@ -55,11 +55,6 @@ class ApplicationController < ActionController::Base
private
def render_no_content(record)
helpers.render_errors(record)
render html: nil, layout: true
end
def rescue_turbo(exception)
raise unless request.format.to_sym == :turbo_stream

View File

@@ -1,54 +0,0 @@
class Default::UnitsController < ApplicationController
navigation_tab :units
before_action :find_unit, only: :export
before_action :find_unit_default, only: [:import, :destroy]
before_action only: :import do
raise AccessForbidden unless current_user.at_least(:active)
end
before_action except: [:index, :import] do
raise AccessForbidden unless current_user.at_least(:admin)
end
def index
@units = current_user.units.defaults_diff.includes(:base, :subunits).ordered
end
def import
@unit.port!(current_user)
flash.now[:notice] = t('.success', unit: @unit)
ensure
run_and_render :index
end
#def import_all
# From defaults_diff return not only portability, but reason for not being
# portable: missing_base and nesting_too_deep. Add portable and
# missing_base, if possible in one query
#end
def export
@unit.port!(nil)
flash.now[:notice] = t('.success', unit: @unit)
ensure
run_and_render :index
end
def destroy
@unit.destroy!
flash.now[:notice] = t('.success', unit: @unit)
ensure
run_and_render :index
end
private
def find_unit
@unit = current_user.units.find_by!(id: params[:id])
end
def find_unit_default
@unit = Unit.find_by!(id: params[:id], user: nil)
end
end

View File

@@ -1,16 +0,0 @@
class MeasurementsController < ApplicationController
def index
@measurements = []
#@measurements = current_user.units.ordered.includes(:base, :subunits)
end
def new
@quantities = current_user.quantities.ordered
end
def create
end
def destroy
end
end

View File

@@ -1,69 +0,0 @@
class QuantitiesController < ApplicationController
before_action only: :new do
find_quantity if params[:id].present?
end
before_action :find_quantity, only: [:edit, :update, :reparent, :destroy]
before_action except: :index do
raise AccessForbidden unless current_user.at_least(:active)
end
def index
@quantities = current_user.quantities.ordered.includes(:parent, :subquantities)
end
def new
@quantity = current_user.quantities.new(parent: @quantity)
end
def create
@quantity = current_user.quantities.new(quantity_params)
if @quantity.save
@before = @quantity.successive
@ancestors = @quantity.ancestors
flash.now[:notice] = t('.success', quantity: @quantity)
else
render :new
end
end
def edit
end
def update
if @quantity.update(quantity_params.except(:parent_id))
@ancestors = @quantity.ancestors
flash.now[:notice] = t('.success', quantity: @quantity)
else
render :edit
end
end
def reparent
permitted = params.require(:quantity).permit(:parent_id)
@previous_ancestors = @quantity.ancestors
# Until UI blocks all disallowed reparents, render error messages if present
render_no_content(@quantity) unless @quantity.update(permitted)
@ancestors = @quantity.ancestors
@self_and_progenies = @quantity.with_progenies
@before = @self_and_progenies.last.successive
end
def destroy
@quantity.destroy!
@ancestors = @quantity.ancestors
flash.now[:notice] = t('.success', quantity: @quantity)
end
private
def quantity_params
params.require(:quantity).permit(Quantity::ATTRIBUTES)
end
def find_quantity
@quantity = current_user.quantities.find_by!(id: params[:id])
end
end

View File

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

View File

@@ -1,8 +1,5 @@
class User::ProfilesController < Devise::RegistrationsController
def destroy
# TODO: Disallow/disable deletion for last admin account; update :edit view
super
end
class RegistrationsController < Devise::RegistrationsController
before_action :authenticate_user!, only: [:edit, :update, :destroy]
protected

View File

@@ -0,0 +1,10 @@
class Units::DefaultsController < ApplicationController
navigation_tab :units
before_action except: :index do
raise AccessForbidden unless current_user.at_least(:admin)
end
def index
end
end

View File

@@ -1,5 +1,5 @@
class UnitsController < ApplicationController
before_action only: :new do
before_action only: [:new] do
find_unit if params[:id].present?
end
before_action :find_unit, only: [:edit, :update, :rebase, :destroy]
@@ -9,7 +9,7 @@ class UnitsController < ApplicationController
end
def index
@units = current_user.units.ordered.includes(:base, :subunits)
@units = current_user.units.includes(:subunits)
end
def new
@@ -19,8 +19,8 @@ class UnitsController < ApplicationController
def create
@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")
run_and_render :index
else
render :new
end
@@ -31,39 +31,37 @@ class UnitsController < ApplicationController
def update
if @unit.update(unit_params.except(:base_id))
flash.now[:notice] = t('.success', unit: @unit)
flash.now[:notice] = t(".success")
run_and_render :index
else
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
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!(permitted)
@before = @unit.successive
if @unit.multiplier_previously_changed?
flash.now[:notice] = t(".multiplier_reset", unit: @unit)
if permitted[:base_id].blank? && @unit.multiplier != 1
permitted.merge!(multiplier: 1)
flash.now[:notice] = t(".multiplier_reset", symbol: @unit.symbol)
end
run_and_render :index if @unit.update(permitted)
end
def destroy
@unit.destroy!
flash.now[:notice] = t('.success', unit: @unit)
if @unit.destroy
flash.now[:notice] = t(".success")
end
run_and_render :index
end
private
def unit_params
params.require(:unit).permit(Unit::ATTRIBUTES)
params.require(:unit).permit(:symbol, :name, :base_id, :multiplier)
end
def find_unit
@unit = current_user.units.find_by!(id: params[:id])
@unit = Unit.find_by!(id: params[:id], user: current_user)
end
end

View File

@@ -2,13 +2,12 @@ class UsersController < ApplicationController
helper_method :allow_disguise?
before_action :find_user, only: [:show, :update, :disguise]
before_action only: :revert do
raise AccessForbidden unless current_user_disguised?
end
before_action except: :revert do
raise AccessForbidden unless current_user.at_least(:admin)
end
before_action only: :revert do
raise AccessForbidden unless current_user_disguised?
end
def index
@users = User.all
@@ -26,7 +25,6 @@ class UsersController < ApplicationController
raise ParameterInvalid unless allow_disguise?(@user)
session[:revert_to_id] = current_user.id
bypass_sign_in(@user)
# TODO: add flash with disguised username?
redirect_to root_url
end
@@ -37,7 +35,7 @@ class UsersController < ApplicationController
end
# NOTE: limited actions availabe to :admin by design. Users are meant to
# manage their accounts by themselves through profiles. :admin
# manage their accounts by themselves through registrations. :admin
# is allowed to sign-in (disguise) as user and make changes from there.
protected

View File

@@ -1,275 +1,149 @@
module ApplicationHelper
class LabeledFormBuilder < ActionView::Helpers::FormBuilder
(field_helpers - [:label, :hidden_field]).each do |selector|
# TODO: replace legacy content_tag with tag.tagname
class LabelledFormBuilder < ActionView::Helpers::FormBuilder
(field_helpers - [:label]).each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {})
labeled_field_for(method, options) { super }
labelled_row_for(method, options) { super }
end
RUBY_EVAL
end
def select(method, choices = nil, options = {}, html_options = {})
labeled_field_for(method, options) { super }
labelled_row_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
def submit(value, options = {})
@template.content_tag :tr do
@template.content_tag :td, super, colspan: 2
end
end
def form_for(&block)
@template.content_tag(:table, class: "centered") { yield(self) } +
# Display leftover error messages (there shouldn't be any)
@template.content_tag(:div, @object&.errors.full_messages.join(@template.tag :br))
end
private
def labeled_field_for(method, options)
field = if options.delete(:readonly) then
value = object.public_send(method)
value = @template.l(value) if value.respond_to?(:strftime)
value ||= options[:placeholder]
else
yield
end
label_for(method, options) + field
def labelled_row_for(method, options)
@template.content_tag :tr do
@template.content_tag(:td, label_for(method, options), class: "unwrappable") +
@template.content_tag(:td, options.delete(:readonly) ? @object.public_send(method) : yield,
@object&.errors[method].present? ?
{class: "error", data: {content: @object&.errors.delete(method).join(" and ")}} :
{})
end
end
def label_for(method, options = {})
return "" if (options[:label] == false)
text = options.delete(:label)
text ||= @object.class.human_attribute_name(method).capitalize
classes = @template.class_names(required: options[:required],
error: object.errors[method].present?)
error: @object&.errors[method].present?)
handler = {missing_interpolation_argument_handler: method(:interpolation_missing)}
# Label translation search order:
# controller.action.* => helpers.label.model.* => activerecord.attributes.model.*
# First 2 levels are translated recursively.
label(method, class: classes) do |builder|
translation = I18n.config.with(**handler) { deep_translate(method, **options) }
translation.presence || "#{builder.translation}:"
end
end
def interpolation_missing(key, values, string)
@template.instance_values[key.to_s] || deep_translate(key, **values)
end
# Extension to label text translation:
# * recursive translation,
# * interpolation of (_relative_) translation key names and instance variables,
# * pluralization based on any interpolable value
# TODO: add unit tests for the above
def deep_translate(key, **options)
options[:default] = [
:".#{key}",
:"helpers.label.#{@object_name}.#{key}_html",
:"helpers.label.#{@object_name}.#{key}",
""
]
# At least 1 interpolation key is required for #translate to act on
# missing interpolation arguments (i.e. call custom handler).
# Also setting `key` to nil avoids recurrent translation.
options[key] = nil
@template.t(".#{key}_html", **options) do |translation, resolved_key|
if translation.is_a?(Array) && (count = translation.to_h[:count])
@template.t(resolved_key, count: I18n.interpolate(count, **options), **options)
else
translation
end
end
label(method, text+":", class: classes) +
(@template.tag(:br) + @template.content_tag(:em, options.delete(:hint)) if options[:hint])
end
end
def labeled_form_for(record, options = {}, &block)
extra_options = {builder: LabeledFormBuilder, html: {class: 'labeled-form'}}
form_for(record, **merge_attributes(options, extra_options), &block)
end
class TabularFormBuilder < ActionView::Helpers::FormBuilder
def initialize(...)
super(...)
@default_options.merge!(@options.slice(:form))
end
[:text_field, :password_field, :text_area].each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {})
options[:maxlength] ||= object.class.type_for_attribute(method).limit
if object.errors.include?(method)
options[:pattern] = except_pattern(object.public_send(method), options[:pattern])
end
super
end
RUBY_EVAL
end
def number_field(method, options = {})
attr_type = object.type_for_attribute(method)
case attr_type.type
when :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]
options[:size] ||= attr_type.precision / 2
when :float
options[:size] ||= 6
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])
)
super
end
private
def submit_default_value
svg_name = object ? (object.persisted? ? 'update' : 'plus-circle-outline') : ''
@template.svg_tag("pictograms/#{svg_name}", super)
end
def except_pattern(value, pattern = nil)
"(?!^#{Regexp.escape(value)}$)" + (pattern || ".*")
end
def labelled_form_for(record, options = {}, &block)
options.merge! builder: LabelledFormBuilder
form_for(record, **options) { |f| f.form_for(&block) }
end
def tabular_fields_for(record_name, record_object = nil, options = {}, &block)
# skip_default_ids causes turbo to generate unique ID for element with
# [autofocus]. Otherwise IDs are not unique when multiple forms are open
# and the first input gets focus.
record_object, options = nil, record_object if record_object.is_a?(Hash)
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)
render_errors(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'}}
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")
def svg_tag(source, options = {})
content_tag :svg, options do
tag.use href: image_path(source + ".svg") + "#icon"
end
label.blank? ? svg_tag : svg_tag + tag.span(label)
end
def navigation_menu
menu_tabs = [
['measurements', 'scale-bathroom', :restricted],
['quantities', 'axis-arrow', :restricted, 'right'],
['units', 'weight-gram', :restricted],
# TODO: display users tab only if >1 user present; sole_user?/sole_admin?
['users', 'account-multiple-outline', :admin],
['units', "weight-kilogram", :restricted, 'right'],
['users', "account-multiple-outline", :admin],
]
menu_tabs.map do |name, image, status, css_class|
if current_user.at_least(status)
link_to svg_tag("pictograms/#{image}", t("#{name}.navigation")),
link_to svg_tag("pictograms/#{image}") + t(".#{name}"),
{controller: "/#{name}", action: "index"},
class: class_names('tab', css_class, active: name == current_tab)
end
end.join.html_safe
end
def image_button_to(name, image = nil, options = nil, html_options = {})
name, html_options = link_or_button_options(:button, name, image, html_options)
button_to name, options, html_options
[:button_to, :link_to, :link_to_unless_current].each do |method_name|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def image_#{method_name}(name, image = nil, options = nil, html_options = {}, &block)
name = svg_tag("pictograms/\#{image}") + name if image
html_options[:class] = class_names(
html_options[:class],
'button',
dangerous: html_options[:method] == :delete
)
if html_options[:onclick]&.is_a? Hash
html_options[:onclick] = "return confirm('\#{html_options[:onclick][:confirm]}');"
end
send :#{method_name}, name, options, html_options, &block
end
RUBY_EVAL
end
def image_button_tag(name, image = nil, html_options = {})
name, html_options = link_or_button_options(:button, name, image, html_options)
button_tag name, html_options
end
def image_link_to(name, image = nil, options = nil, html_options = {})
name, html_options = link_or_button_options(:link, name, image, html_options)
link_to name, options, html_options
end
DISABLED_ATTRIBUTES = {disabled: true, aria: {disabled: true}, tabindex: -1}
def image_button_to_if(condition, name, image = nil, options = nil, html_options = {})
name, html_options = link_or_button_options(:button, name, image, html_options)
html_options = html_options.deep_merge DISABLED_ATTRIBUTES unless condition
button_to name, options, html_options
end
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)
# 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_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 }
def render_errors(record)
flash.now[:alert] = record.errors.full_messages unless record.errors.empty?
end
def render_flash_messages
flash.map do |entry, messages|
# Conversion of flash to Array only required because of Devise
Array(messages).map do |message|
tag.div class: "flash #{entry}" do
tag.span(sanitize(message)) +
svg_tag('pictograms/close-outline', {onclick: "this.parentElement.remove()"})
tag.div(sanitize(message)) + tag.button(sanitize("&times;"), tabindex: -1,
onclick: "this.parentElement.remove();")
end
end
end.join.html_safe
end
def render_no_items
tag.tr id: :no_items do
tag.td t("#{controller_path.tr('/', '.')}.no_items"), colspan: 10, class: 'hint'
end
tag.tr tag.td t('.no_items'), colspan: 10, class: 'hint'
end
def render_turbo_stream(partial, locals = {})
def render_turbo_stream(partial, locals)
"Turbo.renderStreamMessage('#{j(render partial: partial, locals: locals)}'); return false;"
end
private
def link_or_button_options(type, name, image = nil, html_options)
html_options[:class] = class_names(
html_options[:class],
'button',
dangerous: html_options[:method] == :delete
)
# Converts value to HTML formatted scientific notation
def scientifize(d)
sign, coefficient, base, exponent = d.split
return 'NaN' unless sign
if html_options[:onclick]&.is_a? Hash
html_options[:onclick] = "return confirm('\#{html_options[:onclick][:confirm]}');"
result = (sign == -1 ? '-' : '')
unless coefficient == '1' && sign == 1
if coefficient.length > 1
result += coefficient.insert(1, '.')
elsif
result += coefficient
end
if exponent != 1
result += "&times;"
end
end
link_is_local = html_options[:onclick] || html_options.dig(:data, :turbo_stream)
name = name.to_s
name += '...' if type == :link && !link_is_local
name = svg_tag("pictograms/#{image}", name) if image
[name, html_options]
end
# Like Hash#deep_merge, but aware of HTML attributes.
def merge_attributes(left, right)
left.deep_merge(right) do |key, lvalue, rvalue|
key == :class ? class_names(lvalue, rvalue) : rvalue
if exponent != 1
result += "10<sup>% d</sup>" % [exponent-1]
end
result.html_safe
end
end

View File

@@ -1,2 +0,0 @@
module Default::UnitsHelper
end

View File

@@ -1,2 +0,0 @@
module MeasurementsHelper
end

View File

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

View File

@@ -0,0 +1,2 @@
module Units::DefaultsHelper
end

View File

@@ -1,2 +0,0 @@
module UnitsHelper
end

View File

@@ -2,226 +2,86 @@
// https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
/* Hide page before loaded for testing purposes */
function showPage(event) {
document.documentElement.style.visibility="visible"
}
document.addEventListener('turbo:load', showPage)
function detailsChange(event) {
var target = event.currentTarget
var count = target.querySelectorAll('input:checked:not([disabled])').length
var span = target.querySelector('summary > span')
var button = target.querySelector('button')
if (count > 0) {
span.textContent = count + ' selected';
Turbo.StreamElement.prototype.enableElement(button)
} else {
span.textContent = span.getAttribute('data-prompt')
Turbo.StreamElement.prototype.disableElement(button)
}
}
window.detailsChange = detailsChange
/* Close open <details> when focus lost */
function detailsClose(event) {
if (!event.relatedTarget ||
event.relatedTarget.closest("details") != event.currentTarget) {
event.currentTarget.removeAttribute("open")
}
}
window.detailsClose = detailsClose
window.detailsObserver = new MutationObserver((mutations) => {
mutations[0].target.dispatchEvent(new Event('change', {bubbles: true}))
});
function formValidate(event) {
var id = event.submitter.getAttribute("data-validate")
if (!id) return;
var input = document.getElementById(id)
if (!input.checkValidity()) {
input.reportValidity()
event.preventDefault()
}
}
window.formValidate = formValidate
/* Turbo stream actions */
Turbo.StreamElement.prototype.disableElement = function(element) {
element.setAttribute("disabled", "disabled")
element.setAttribute("aria-disabled", "true")
element.setAttribute("tabindex", "-1")
}
Turbo.StreamActions.disable = function() {
this.targetElements.forEach((e) => { this.disableElement(e) })
}
Turbo.StreamElement.prototype.enableElement = function(element) {
element.removeAttribute("disabled")
element.removeAttribute("aria-disabled")
// Assume 'tabindex' is not used explicitly, so removing it is safe
// 'tabindex' is not used explicitly, so removing it is safe
element.removeAttribute("tabindex")
}
Turbo.StreamElement.prototype.removePreviousForm = function(form) {
const id = form.id
const row = document.getElementById(id + "_cached")
form.remove()
if (row) {
row.id = id
row.style.display = "revert"
}
if (form.hasAttribute("data-link-id")) {
const link = document.getElementById(form.getAttribute("data-link-id"))
this.enableElement(link)
}
}
Turbo.StreamActions.disable = function() {
this.targetElements.forEach((e) => {
e.setAttribute("disabled", "disabled")
e.setAttribute("aria-disabled", "true")
e.setAttribute("tabindex", "-1")
})
}
Turbo.StreamActions.enable = function() {
this.targetElements.forEach((e) => { this.enableElement(e) })
}
/* TODO: change to visibility = collapse to avoid width change? */
Turbo.StreamActions.hide = function() {
this.targetElements.forEach((e) => { e.style.display = "none" })
Turbo.StreamActions.blur = function() {
blur()
}
Turbo.StreamActions.show = function() {
this.targetElements.forEach((e) => { e.style.removeProperty("display") })
Turbo.StreamActions.focus = function() {
// NOTE: call blur() before setting focus?
this.targetElements[0].focus({focusVisible: true})
}
/*
Turbo.StreamActions.collapse = function() {
this.targetElements.forEach((e) => { e.style.visibility = "collapse" })
Turbo.StreamActions.prepend_form = function() {
this.targetElements.forEach((e) => {
[...e.getElementsByClassName("form")].forEach((f) => {
this.removePreviousForm(f)
})
e.prepend(this.templateContent)
})
}
Turbo.StreamActions.after_form = function() {
this.targetElements.forEach((e) => {
[...e.parentElement?.getElementsByClassName("form")].forEach((f) => {
this.removePreviousForm(f)
})
e.parentElement?.insertBefore(this.templateContent, e.nextSibling)
})
}
Turbo.StreamActions.replace_form = function() {
this.targetElements.forEach((e) => {
[...e.parentElement?.getElementsByClassName("form")].forEach((f) => {
this.removePreviousForm(f)
})
e.style.display = "none"
e.id = e.id + "_cached"
e.parentElement?.insertBefore(this.templateContent, e.nextSibling)
})
}
*/
Turbo.StreamActions.close_form = function() {
this.targetElements.forEach((e) => {
/* Move focus if there's no focus or focus inside form being closed */
const focused = document.activeElement
if (!focused || (focused == document.body) || e.contains(focused)) {
let nextForm = e.parentElement.querySelector(`#${e.id} ~ tr:has([autofocus])`)
nextForm ??= e.parentElement.querySelector("tr:has([autofocus])")
nextForm?.querySelector("[autofocus]").focus()
}
document.getElementById(e.getAttribute("data-form")).remove()
if (e.hasAttribute("data-link")) {
this.enableElement(document.getElementById(e.getAttribute("data-link")))
}
if (e.hasAttribute("data-hidden-row")) {
document.getElementById(e.getAttribute("data-hidden-row")).removeAttribute("style")
}
e.remove()
this.removePreviousForm(e.closest(".form"))
})
}
Turbo.StreamActions.unselect = function() {
this.targetElements.forEach((e) => {
e.checked = false
this.enableElement(e)
})
}
function formProcessKey(event) {
switch (event.key) {
case "Escape":
event.currentTarget.querySelector("a[name=cancel]").click()
break
case "Enter":
event.currentTarget.querySelector("button[name=button]").click()
event.preventDefault()
break
}
}
window.formProcessKey = formProcessKey
function detailsProcessKey(event) {
// TODO: up/down arrows to move focus to prev/next line
switch (event.key) {
case "Escape":
if (event.currentTarget.hasAttribute("open")) {
event.currentTarget.removeAttribute("open")
event.stopPropagation()
}
break
case "Enter":
var button = event.currentTarget.querySelector("button:not([disabled])")
if (button) {
button.click()
// Autofocus won't be respected unless target is blurred
event.target.blur()
event.preventDefault()
event.stopPropagation()
}
break
}
}
window.detailsProcessKey = detailsProcessKey;
/* Items table drag and drop support */
var lastEnterTime
function dragStart(event) {
lastEnterTime = event.timeStamp
var row = event.currentTarget
row.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden")
})
event.dataTransfer.setData("text/plain", row.getAttribute("data-drag-path"))
var rowRectangle = row.getBoundingClientRect()
event.dataTransfer.setDragImage(row, event.x - rowRectangle.left, event.y - rowRectangle.top)
event.dataTransfer.dropEffect = "move"
}
window.dragStart = dragStart
/*
* Drag tracking assumptions (based on FF 122.0 experience):
* * Enter/Leave events at the same timeStamp may not be logically ordered
* (e.g. E -> E -> L, not E -> L -> E),
* * not every Enter event has corresponding Leave event, especially during
* rapid pointer moves
* NOTE: sometimes Leave is not emitted when pointer goes fast over table
* and outside. This should probably be fixed in browser, than patched here.
*/
function dragEnter(event) {
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id)
dragLeave(event)
lastEnterTime = event.timeStamp
const id = event.currentTarget.getAttribute("data-drop-id")
document.getElementById(id).classList.add("dropzone")
}
window.dragEnter = dragEnter
function dragOver(event) {
event.preventDefault()
}
window.dragOver = dragOver
function dragLeave(event) {
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id)
// Leave has been accounted for by Enter at the same timestamp, processed earlier
if (event.timeStamp <= lastEnterTime) return
event.currentTarget.closest("table").querySelectorAll(".dropzone").forEach((tr) => {
tr.classList.remove("dropzone")
})
}
window.dragLeave = dragLeave
function dragEnd(event) {
dragLeave(event)
event.currentTarget.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden")
})
}
window.dragEnd = dragEnd
function drop(event) {
event.preventDefault()
var params = new URLSearchParams()
var id_param = event.currentTarget.getAttribute("data-drop-id-param")
var id = event.currentTarget.getAttribute("data-drop-id").split("_").pop()
params.append(id_param, id)
fetch(event.dataTransfer.getData("text/plain"), {
body: params,
headers: {
"Accept": "text/vnd.turbo-stream.html",
"X-CSRF-Token": document.head.querySelector("meta[name=csrf-token]").content,
"X-Requested-With": "XMLHttpRequest"
},
method: "POST"
})
.then(response => response.text())
.then(html => Turbo.renderStreamMessage(html))
}
window.drop = drop

View File

@@ -1,17 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
class << self
# Cached attribute has non-user assignable value calculated from other
# attributes' values on create/update. This simplifies and speeds up
# actions, especially for recursively calculated values. Because value can
# be changed on update, it is not same as #attr_readonly.
def attr_cached(*names)
names.each { |name| alias_method :"#{name}=", :assign_cached_attribute }
end
end
def assign_cached_attribute(value)
raise ActiveRecord::ReadonlyAttributeError
end
primary_abstract_class
end

View File

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

View File

@@ -1,175 +0,0 @@
class Quantity < ApplicationRecord
ATTRIBUTES = [:name, :description, :parent_id]
attr_cached :depth, :pathname
belongs_to :user, optional: true
belongs_to :parent, optional: true, class_name: "Quantity"
has_many :subquantities, ->{ order(:name) }, class_name: "Quantity",
inverse_of: :parent, 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
errors.add(:parent, :descendant_reference) if ancestor_of?(parent)
end
validates :name, presence: true, uniqueness: {scope: [:user_id, :parent_id]},
length: {maximum: type_for_attribute(:name).limit}
validates :description, length: {maximum: type_for_attribute(:description).limit}
# Update :depths of progenies after parent change
before_save if: :parent_changed? do
self[:depth] = parent&.depth&.succ || 0
end
after_update if: :depth_previously_changed? do
quantities = Quantity.arel_table
selected = Arel::Table.new('selected')
Quantity.with_recursive(selected: [
quantities.project(quantities[:id].as('quantity_id'), quantities[:depth])
.where(quantities[:id].eq(id)),
quantities.project(quantities[:id], selected[:depth] + 1)
.join(selected).on(selected[:quantity_id].eq(quantities[:parent_id]))
]).joins(:selected).update_all(depth: selected[:depth])
end
# Update :pathnames of progenies after parent/name change
PATHNAME_DELIMITER = ' → '
before_save if: -> { parent_changed? || name_changed? } do
self[:pathname] = (parent ? parent.pathname + PATHNAME_DELIMITER : '') + self[:name]
end
after_update if: :pathname_previously_changed? do
quantities = Quantity.arel_table
selected = Arel::Table.new('selected')
Quantity.with_recursive(selected: [
quantities.project(quantities[:id].as('quantity_id'), quantities[:pathname])
.where(quantities[:id].eq(id)),
quantities.project(
quantities[:id],
selected[:pathname].concat(Arel::Nodes.build_quoted(PATHNAME_DELIMITER))
.concat(quantities[:name])
).join(selected).on(selected[:quantity_id].eq(quantities[:parent_id]))
]).joins(:selected).update_all(pathname: selected[:pathname])
end
scope :defaults, ->{ where(user: nil) }
# Return: ordered [sub]hierarchy
scope :ordered, ->(root: nil, include_root: true) {
numbered = Arel::Table.new('numbered')
self.model.with(numbered: numbered(:parent_id, :name)).with_recursive(arel_table.name => [
numbered.project(
numbered[Arel.star],
numbered.cast(numbered[:child_number], 'BINARY').as('path')
).where(numbered[root && include_root ? :id : :parent_id].eq(root)),
numbered.project(
numbered[Arel.star],
arel_table[:path].concat(numbered[:child_number])
).join(arel_table).on(numbered[:parent_id].eq(arel_table[:id]))
]).order(arel_table[:path])
}
# TODO: extract named functions to custom Arel extension
# NOTE: once recursive queries allow use of window functions, this scope can
# be merged with :ordered
# https://gist.github.com/ProGM/c6df08da14708dcc28b5ca325df37ceb#extending-arel
scope :numbered, ->(parent_column, order_column) {
select(
arel_table[Arel.star],
Arel::Nodes::NamedFunction.new(
'LPAD',
[
Arel::Nodes::NamedFunction.new('ROW_NUMBER', [])
.over(Arel::Nodes::Window.new.partition(parent_column).order(order_column)),
Arel::SelectManager.new.project(
Arel::Nodes::NamedFunction.new('LENGTH', [Arel.star.count])
).from(arel_table),
Arel::Nodes.build_quoted('0')
],
).as('child_number')
)
}
def to_s
name
end
def to_s_with_depth
# em space, U+2003
'' * depth + name
end
def destroyable?
subquantities.empty?
end
def default?
parent_id.nil?
end
# Common ancestors, assuming node is a descendant of itself
scope :common_ancestors, ->(of) {
selected = Arel::Table.new('selected')
# Take unique IDs, so self can be called with parent nodes of collection to
# get common ancestors of collection _excluding_ nodes in collection.
uniq_of = of.uniq
model.with(selected: self).with_recursive(arel_table.name => [
selected.project(selected[Arel.star]).where(selected[:id].in(uniq_of)),
selected.project(selected[Arel.star])
.join(arel_table).on(selected[:id].eq(arel_table[:parent_id]))
]).select(arel_table[Arel.star])
.group(column_names)
.having(arel_table[:id].count.eq(uniq_of.size))
.order(arel_table[:depth].desc)
}
# Return: successive record in order of appearance; used for partial view reload
def successive
quantities = Quantity.arel_table
Quantity.with(
quantities: user.quantities.ordered.select(
quantities[Arel.star],
Arel::Nodes::NamedFunction.new('LAG', [quantities[:id]]).over.as('lag_id')
)
).where(quantities[:lag_id].eq(id)).first
end
def with_progenies
user.quantities.ordered(root: id).to_a
end
def progenies
user.quantities.ordered(root: id, include_root: false).to_a
end
# Return: record with ID `of` with its ancestors, sorted by :depth
scope :with_ancestors, ->(of) {
selected = Arel::Table.new('selected')
model.with(selected: self).with_recursive(arel_table.name => [
selected.project(selected[Arel.star]).where(selected[:id].eq(of)),
selected.project(selected[Arel.star])
.join(arel_table).on(selected[:id].eq(arel_table[:parent_id]))
])
}
# Return: ancestors of (possibly destroyed) self
def ancestors
user.quantities.with_ancestors(parent_id).order(:depth).to_a
end
def ancestor_of?(progeny)
user.quantities.with_ancestors(progeny.id).exists?(id)
end
def relative_pathname(ancestor)
pathname.delete_prefix(ancestor ? ancestor.pathname + PATHNAME_DELIMITER : '')
end
end

View File

@@ -1,7 +0,0 @@
class Readout < ApplicationRecord
ATTRIBUTES = [:quantity_id, :value, :unit_id]
belongs_to :user
belongs_to :quantity
belongs_to :unit
end

View File

@@ -1,124 +1,34 @@
class Unit < ApplicationRecord
ATTRIBUTES = [:symbol, :description, :multiplier, :base_id]
belongs_to :user, optional: true
belongs_to :base, optional: true, class_name: "Unit"
has_many :subunits, class_name: "Unit", inverse_of: :base,
dependent: :restrict_with_error
has_many :subunits, class_name: "Unit", dependent: :restrict_with_error, inverse_of: :base
validate if: ->{ base.present? } do
errors.add(:base, :user_mismatch) unless user_id == base.user_id
errors.add(:base, :self_reference) if id == base_id
errors.add(:base, :multilevel_nesting) if base.base_id?
errors.add(:base, :user_mismatch) unless user == base.user
errors.add(:base, :multilevel_nesting) if base.base.present?
end
validates :symbol, presence: true, uniqueness: {scope: :user_id},
length: {maximum: type_for_attribute(:symbol).limit}
validates :description, length: {maximum: type_for_attribute(:description).limit}
length: {maximum: columns_hash['symbol'].limit}
validates :name, length: {maximum: columns_hash['name'].limit}
validates :multiplier, numericality: {equal_to: 1}, unless: :base
validates :multiplier, numericality: {greater_than: 0, precision: true, scale: true}, if: :base
validates :multiplier, numericality: {other_than: 0}, if: :base
scope :defaults, ->{ where(user: nil) }
scope :defaults_diff, ->{
actionable_units = Arel::Table.new('actionable_units')
units = actionable_units.alias('units')
bases_units = arel_table.alias('bases_units')
other_units = arel_table.alias('other_units')
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)
.where.not(
# Exclude Units that are/have default counterpart
Arel::SelectManager.new.project(1).from(other_units)
.outer_join(other_bases_units)
.on(other_units[:base_id].eq(other_bases_units[:id]))
.where(
other_bases_units[:symbol].is_not_distinct_from(bases_units[:symbol])
.and(other_units[:symbol].eq(arel_table[:symbol]))
.and(other_units[:user_id].is_distinct_from(arel_table[:user_id]))
).exists
)
.select(
arel_table[Arel.star],
# Decide if Unit can be im-/exported based on existing hierarchy:
# * same base unit symbol has to exist
# * unit with subunits can only be ported to root
arel_table[:base_id].eq(nil).or(
(
Arel::SelectManager.new.project(1).from(other_units)
.join(sub_units).on(other_units[:id].eq(sub_units[:base_id]))
.where(
other_units[:symbol].eq(arel_table[:symbol])
.and(other_units[:user_id].is_distinct_from(arel_table[:user_id]))
).exists.not
).and(
Arel::SelectManager.new.project(1).from(other_bases_units)
.where(
other_bases_units[:symbol].is_not_distinct_from(bases_units[:symbol])
.and(other_bases_units[:user_id].is_distinct_from(bases_units[:user_id]))
).exists
)
).as('portable')
),
# 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'))
]).select(units: [:base_id, :symbol])
.select(
units[:id].minimum.as('id'), # can be ANY_VALUE()
units[:user_id].minimum.as('user_id'), # prefer non-default
Arel::Nodes.build_quoted(1).as('multiplier'), # disregard multiplier when sorting
units[:portable].minimum.as('portable')
)
.from(units).group(:base_id, :symbol)
}
scope :ordered, ->{
left_outer_joins(:base).order(ordering)
parent_symbol = Arel::Nodes::NamedFunction.new(
'COALESCE',
[Arel::Table.new(:bases_units)[:symbol], arel_table[:symbol]]
)
left_outer_joins(:base)
.order(parent_symbol, arel_table[:base_id].asc.nulls_first, :multiplier, :symbol)
}
def self.ordering
[arel_table.coalesce(Arel::Table.new(:bases_units)[:symbol], arel_table[:symbol]),
arel_table[:base_id].not_eq(nil),
:multiplier,
:symbol]
end
before_destroy do
# TODO: disallow destruction if any object depends on this unit
nil
end
def to_s
symbol
end
def movable?
subunits.empty?
end
def default?
user_id.nil?
end
# Should only by invoked on Units returned from #defaults_diff which are #portable
def port!(recipient)
recipient_base = base && Unit.find_by!(symbol: base.symbol, user: recipient)
params = slice(ATTRIBUTES - [:symbol, :base_id])
Unit.find_or_initialize_by(user: recipient, symbol: symbol)
.update!(base: recipient_base, **params)
end
def successive
units = Unit.arel_table
lead = Arel::Nodes::NamedFunction.new('LAG', [units[:id]])
window = Arel::Nodes::Window.new.order(*Unit.ordering)
lag_id = lead.over(window).as('lag_id')
Unit.with(
units: user.units.left_outer_joins(:base).select(units[Arel.star], lag_id)
).where(units[:lag_id].eq(id)).first
end
end

View File

@@ -7,24 +7,11 @@ class User < ApplicationRecord
admin: 4, # admin level access
active: 3, # read-write user level access
restricted: 2, # read-only user level access
locked: 1, # disallowed to sign in due to failed logins; maintained by
# Devise :lockable
locked: 1, # disallowed to sign in due to failed logins; maintained by Devise :lockable
disabled: 0, # administratively disallowed to sign in
}, default: :active, validate: true
}, default: :active
has_many :readouts, dependent: :delete_all
accepts_nested_attributes_for :readouts
has_many :quantities, dependent: :delete_all
has_many :units, dependent: :delete_all
validates :email, presence: true, uniqueness: true,
length: {maximum: type_for_attribute(:email).limit}
validates :unconfirmed_email,
length: {maximum: type_for_attribute(:unconfirmed_email).limit}
def to_s
email
end
has_many :units, -> { ordered }, dependent: :destroy
def at_least(status)
User.statuses[self.status] >= User.statuses[status]

View File

@@ -1,27 +0,0 @@
<%= tag.tr do %>
<td class="<%= class_names({grayed: unit.default?}) %>"
style="--depth:<%= unit.base_id? ? 1 : 0 %>">
<%= unit %>
</td>
<% if current_user.at_least(:active) %>
<td class="flex">
<% unless unit.portable.nil? %>
<% if unit.default? %>
<%= image_button_to_if unit.portable?, t('.import'), 'download-outline',
import_default_unit_path(unit) %>
<% end %>
<% if current_user.at_least(:admin) %>
<% if unit.default? %>
<%= image_button_to_if unit.movable?, t('.delete'), 'delete-outline',
default_unit_path(unit), method: :delete %>
<% else %>
<%= image_button_to_if unit.portable?, t('.export'), 'upload-outline',
export_default_unit_path(unit) %>
<% end %>
<% end %>
<% end %>
</td>
<% end %>
<% end %>

View File

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

View File

@@ -5,41 +5,42 @@
link during this period, request will be sent outside of Turbo, resulting in
e.g. stream request sent as HTML instead of TURBO_STREAM.
Content is shown on 'turbo:load' event.
TODO: hide only in TEST environment?
-->
<html<%= ' style="visibility: hidden;"' if Rails.env.test? -%>>
<html style="visibility: hidden;">
<head>
<title>fixin.me</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "spreadsheet" %>
<%= stylesheet_link_tag "application" %>
<%= javascript_importmap_tags %>
<%#= turbo_page_requires_reload_tag %>
<%= turbo_exempts_page_from_cache_tag %>
<%# TODO: replace with turbo_disable_prefetch_tag when available %>
<%= tag.meta(name: "turbo-prefetch", content: false) %>
<%# TODO: replace with: turbo_page_requires_reload_tag when available %>
<%#= tag.meta(name: "turbo-visit-control", content: "reload") %>
<%# TODO: replace with: turbo_exempts_page_from_cache_tag when available %>
<%= tag.meta(name: "turbo-cache-control", content: "no-cache") %>
</head>
<body>
<header class="flex">
<header class="header">
<%= 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) %>
<%= image_link_to_unless_current(current_user.email, "account-wrench-outline",
edit_user_registration_path) {} %>
<% if current_user_disguised? %>
<%= image_link_to t(".revert"), "incognito-off", revert_users_path %>
<% else %>
<%= image_button_to t(".sign_out"), "logout", destroy_user_session_path,
method: :delete, data: {turbo: false} %>
method: :delete %>
<% end %>
<% else %>
<%= image_link_to_unless_current(t(:sign_in), "login", new_user_session_path) %>
<%= image_link_to_unless_current(t(:sign_in), "login", new_user_session_path) {} %>
<%= image_link_to_unless_current(t(:register), "account-plus-outline",
new_user_registration_path) %>
new_user_registration_path) {} %>
<% end %>
</header>
@@ -47,7 +48,7 @@
<%= render_flash_messages %>
</div>
<%# Allows overwriting/clearing navigation menu for some views %>
<%# Allow overwriting/clearing navigation menu for some views %>
<nav class="navigation">
<%= content_for(:navigation) || (navigation_menu if user_signed_in?) %>
</nav>

View File

@@ -1,45 +0,0 @@
<%= 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>
</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">
<%= form.button id: :create_measurement_button, disabled: true -%>
<%= image_link_to t(:cancel), "close-outline", measurements_path, name: :cancel,
class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</div>
<% end %>
<script>
quantity_select.addEventListener('focusout', detailsClose)
quantity_select.addEventListener('change', detailsChange)
detailsObserver.observe(quantity_select.querySelector('ul'),
{subtree: true, attributeFilter: ['disabled']})
</script>

View File

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

View File

@@ -1,14 +0,0 @@
<%# TODO: show hint when no quantities/units defined %>
<div class="rightside-area buttongrid">
<% if current_user.at_least(:active) %>
<%= image_link_to t('.new_measurement'), 'plus-outline', new_measurement_path,
id: :new_measurement_link, onclick: 'this.blur();',
data: {turbo_stream: true} %>
<% end %>
</div>
<table class="main-area">
<tbody id="measurements">
<%= render(@measurements) || render_no_items %>
</tbody>
</table>

View File

@@ -1,5 +0,0 @@
<%= turbo_stream.disable :new_measurement_link -%>
<%= turbo_stream.hide :no_items -%>
<%= turbo_stream.append_all 'body' do %>
<%= render partial: 'form' %>
<% end %>

View File

@@ -1,19 +0,0 @@
<%= tabular_fields_for @quantity, form: form_tag do |form| %>
<%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {link: link, form: form_tag, hidden_row: hidden_row} do %>
<td style="--depth:<%= @quantity.depth %>">
<%= form.text_field :name, required: true, autofocus: true, size: 20 %>
</td>
<td>
<%= form.text_area :description, cols: 30, rows: 1, escape: false %>
</td>
<td class="flex">
<%= form.button %>
<%= image_link_to t(:cancel), "close-outline", quantities_path, class: 'dangerous',
name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %>
</td>
<td></td>
<% end %>
<% end %>

View File

@@ -1,2 +0,0 @@
<%= turbo_stream.close_form row %>
<%= turbo_stream.update :flashes %>

View File

@@ -1,24 +0,0 @@
<%= tag.tr id: dom_id(quantity),
ondragstart: "dragStart(event)", ondragend: "dragEnd(event)",
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drag_path: reparent_quantity_path(quantity), drop_id: dom_id(quantity),
drop_id_param: "quantity[parent_id]"} do %>
<td style="--depth:<%= quantity.depth %>">
<%= link_to quantity, edit_quantity_path(quantity), class: 'link',
onclick: 'this.blur();', data: {turbo_stream: true} %>
</td>
<td><%= quantity.description %></td>
<% if current_user.at_least(:active) %>
<td class="flex">
<%= 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} %>
<%= image_button_to_if quantity.destroyable?, t('.destroy'), 'delete-outline',
quantity_path(quantity), method: :delete %>
</td>
<td class="handle" draggable="true">&#x283F</td>
<% end %>
<% end %>

View File

@@ -1,7 +0,0 @@
<%= turbo_stream.close_form dom_id(@quantity.parent || Quantity, :new) %>
<%= turbo_stream.remove :no_items %>
<% @ancestors.each do |ancestor| %>
<%= turbo_stream.replace ancestor %>
<% end %>
<%= @before ? turbo_stream.before(@before, @quantity) :
turbo_stream.append(:quantities, @quantity) %>

View File

@@ -1,5 +0,0 @@
<% @ancestors.each do |ancestor| %>
<%= turbo_stream.replace ancestor %>
<% end %>
<%= turbo_stream.remove @quantity %>
<%= turbo_stream.append(:quantities, render_no_items) if current_user.quantities.empty? %>

View File

@@ -1,13 +0,0 @@
<% ids = {row: dom_id(@quantity, :edit),
hidden_row: dom_id(@quantity),
link: nil,
form_tag: dom_id(@quantity, :edit, :form)} %>
<%= turbo_stream.append :quantity_form do %>
<%- tabular_form_with model: @quantity, html: {id: ids[:form_tag]} do %>
<% end %>
<% end %>
<%= turbo_stream.hide ids[:hidden_row] %>
<%= turbo_stream.remove ids[:row] %>
<%= turbo_stream.after @quantity, partial: 'form', locals: ids -%>

View File

@@ -1,34 +0,0 @@
<div class="rightside-area buttongrid">
<% if current_user.at_least(:active) %>
<%= image_link_to t('.new_quantity'), 'plus-outline', new_quantity_path,
id: dom_id(Quantity, :new, :link), onclick: 'this.blur();',
data: {turbo_stream: true} %>
<% end %>
<%#= image_link_to t('.import_quantities'), 'download-outline', default_quantities_path,
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">
<thead>
<tr>
<th><%= Quantity.human_attribute_name(:name) %></th>
<th class="hexpand"><%= Quantity.human_attribute_name(:description) %></th>
<% if current_user.at_least(:active) %>
<th><%= t :actions %></th>
<th></th>
<% end %>
</tr>
<%= tag.tr id: "quantity_", hidden: true,
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drop_id: "quantity_", drop_id_param: "quantity[parent_id]"} do %>
<th colspan="4"><%= t '.top_level_drop' %></th>
<% end %>
</thead>
<tbody id="quantities">
<%= render(@quantities) || render_no_items %>
</tbody>
</table>

View File

@@ -1,20 +0,0 @@
<% dom_obj = @quantity.parent || @quantity %>
<% ids = {row: dom_id(dom_obj, :new),
hidden_row: nil,
link: dom_id(dom_obj, :new, :link),
form_tag: dom_id(dom_obj, :new, :form)} %>
<%= turbo_stream.disable ids[:link] -%>
<%= turbo_stream.append :quantity_form do %>
<%- tabular_form_with model: @quantity, html: {id: ids[:form_tag]} do |form| %>
<%= form.hidden_field :parent_id if @quantity.parent_id? %>
<% end %>
<% end %>
<% if @quantity.parent_id? %>
<%= turbo_stream.remove ids[:row] %>
<%= turbo_stream.after @quantity.parent, partial: 'form', locals: ids %>
<% else %>
<%= turbo_stream.prepend :quantities, partial: 'form', locals: ids %>
<% end %>

View File

@@ -1,9 +0,0 @@
<% @self_and_progenies.each do |q| %>
<%= turbo_stream.remove q %>
<% end %>
<% @previous_ancestors.union(@ancestors).each do |ancestor| %>
<%= turbo_stream.replace ancestor %>
<% end %>
<% @self_and_progenies.each do |q| %>
<%= @before ? turbo_stream.before(@before, q) : turbo_stream.append(:quantities, q) %>
<% end %>

View File

@@ -1,4 +0,0 @@
<%= turbo_stream.close_form dom_id(@quantity, :edit) %>
<% @ancestors.push(@quantity).each do |ancestor| %>
<%= turbo_stream.replace ancestor %>
<% end %>

View File

@@ -1,24 +0,0 @@
<%# 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">
<%# 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>
<% end %>
<% end %>

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,27 @@
<%= tabular_fields_for @unit, form: form_tag do |form| %>
<%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {link: link, form: form_tag, hidden_row: hidden_row} do %>
<%= tabular_fields_for @unit do |form| %>
<%= tag.tr id: dom_id(@unit), class: "form", onkeydown: "processKey(event)",
data: {link_id: link_id} do %>
<td style="--depth:<%= @unit.base_id? ? 1 : 0 %>">
<%= form.text_field :symbol, required: true, autofocus: true, size: 12 %>
<td class="<%= class_names({subunit: @unit.base}) %>">
<%= form.text_field :symbol, form: :unit_form, required: true, autofocus: true, size: 12,
maxlength: @unit.class.columns_hash['symbol'].limit, autocomplete: "off" %>
</td>
<td>
<%= form.text_area :description, cols: 30, rows: 1, escape: false %>
<%= form.text_field :name, form: :unit_form, size: 30,
maxlength: @unit.class.columns_hash['name'].limit, autocomplete: "off" %>
</td>
<td>
<%= form.number_field :multiplier, required: true, size: 10, min: :step if @unit.base_id? %>
<% unless @unit.base.nil? %>
<%= form.hidden_field :base_id, form: :unit_form %>
<%= form.number_field :multiplier, form: :unit_form, required: true, step: "any",
size: 10, autocomplete: "off" %>
<% end %>
</td>
<td class="flex">
<%= form.button %>
<%= image_link_to t(:cancel), "close-outline", units_path, class: 'dangerous',
name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %>
<td class="actions">
<%= form.submit form: :unit_form %>
<%= image_link_to t(:cancel), "close-circle-outline", units_path, class: 'dangerous',
name: :cancel, onclick: render_turbo_stream('form_close', {link_id: link_id}) %>
</td>
<td></td>
<% end %>
<% end %>

View File

@@ -1,2 +1,3 @@
<%= turbo_stream.close_form row %>
<%= turbo_stream.close_form @unit %>
<%= turbo_stream.update :flashes %>
<%#= turbo_stream.focus link_id %>

View File

@@ -1,26 +1,25 @@
<%= tag.tr id: dom_id(unit),
ondragstart: "dragStart(event)", ondragend: "dragEnd(event)",
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drag_path: rebase_unit_path(unit),
drop_id: dom_id(unit.base || unit),
drop_id_param: "unit[base_id]"} do %>
ondragstart: 'dragStart(event)', ondragend: 'dragEnd(event)',
ondragover: 'dragOver(event)', ondrop: 'drop(event)',
ondragenter: 'dragEnter(event)', ondragleave: 'dragLeave(event)',
data: {drag_path: rebase_unit_path(unit), drop_id: dom_id(unit.base || unit)} 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="<%= class_names('link', {subunit: unit.base}) %>">
<%= link_to unit.symbol, edit_unit_path(unit), id: dom_id(unit, :edit),
onclick: 'this.blur();', data: {turbo_stream: true} %>
</td>
<td><%= unit.description %></td>
<td class="ralign"><%= unit.multiplier.to_html %></td>
<td><%= unit.name %></td>
<td class="number"><%= scientifize(unit.multiplier) %></td>
<% if current_user.at_least(:active) %>
<td class="flex">
<% unless unit.base_id? %>
<%= image_link_to t('.new_subunit'), 'plus-outline', new_unit_path(unit),
id: dom_id(unit, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>
<td class="actions">
<% if unit.base.nil? %>
<%= image_link_to t(".add_subunit"), "plus-outline", new_unit_path(unit),
id: dom_id(unit, :add), onclick: 'this.blur();',
data: {turbo_stream: true} %>
<% end %>
<%= image_button_to_if unit.movable?, t('.destroy'), 'delete-outline', unit_path(unit),
<%= image_button_to t(".delete_unit"), "delete-outline", unit_path(unit),
method: :delete %>
</td>
<% if unit.movable? %>

View File

@@ -1,4 +0,0 @@
<%= 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) %>

View File

@@ -0,0 +1,2 @@
<h1>Units::Defaults#index</h1>
<p>Find me in app/views/units/defaults/index.html.erb</p>

View File

@@ -1,3 +0,0 @@
<%= turbo_stream.replace @unit.base if @unit.base_id? %>
<%= turbo_stream.remove @unit %>
<%= turbo_stream.append(:units, render_no_items) if current_user.units.empty? %>

View File

@@ -1,13 +1,6 @@
<% ids = {row: dom_id(@unit, :edit),
hidden_row: dom_id(@unit),
link: nil,
form_tag: dom_id(@unit, :edit, :form)} %>
<%= turbo_stream.append :unit_form do %>
<%- tabular_form_with model: @unit, html: {id: ids[:form_tag]} do %>
<%= turbo_stream.replace :unit_form do %>
<%= form_with model: @unit, html: {id: :unit_form} do %>
<% end %>
<% end %>
<%= turbo_stream.hide ids[:hidden_row] %>
<%= turbo_stream.remove ids[:row] %>
<%= turbo_stream.after @unit, partial: 'form', locals: ids -%>
<%= turbo_stream.replace_form @unit, partial: 'form', locals: {link_id: dom_id(@unit, :edit)} %>

View File

@@ -1,30 +1,29 @@
<div class="rightside-area buttongrid">
<div class="rightside buttongrid">
<% if current_user.at_least(:active) %>
<%= image_link_to t('.new_unit'), 'plus-outline', new_unit_path,
id: dom_id(Unit, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>
<%= image_link_to t('.add_unit'), 'plus-outline', new_unit_path, id: :add_unit,
onclick: 'this.blur();', data: {turbo_stream: true} %>
<%= image_link_to t('.import_units'), 'import', new_unit_path, class: 'tools',
data: {turbo_stream: true} %>
<% end %>
<%= image_link_to t('.import_units'), 'download-outline', default_units_path,
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 items">
<thead>
<tr>
<th><%= Unit.human_attribute_name(:symbol) %></th>
<th class="hexpand"><%= Unit.human_attribute_name(:description) %></th>
<th><%= Unit.human_attribute_name(:multiplier) %></th>
<th><%= User.human_attribute_name(:symbol).capitalize %></th>
<th><%= User.human_attribute_name(:name).capitalize %></th>
<th><%= User.human_attribute_name(:multiplier).capitalize %></th>
<% if current_user.at_least(:active) %>
<th><%= t :actions %></th>
<th></th>
<% end %>
</tr>
<%= tag.tr id: "unit_", hidden: true,
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drop_id: "unit_", drop_id_param: "unit[base_id]"} do %>
<%= tag.tr id: 'unit_', hidden: true,
ondragover: 'dragOver(event)', ondrop: 'drop(event)',
ondragenter: 'dragEnter(event)', ondragleave: 'dragLeave(event)',
data: {drop_id: 'unit_'} do %>
<th colspan="5"><%= t '.top_level_drop' %></th>
<% end %>
</thead>
@@ -32,3 +31,81 @@
<%= render(@units) || render_no_items %>
</tbody>
</table>
<%= javascript_tag do %>
function processKey(event) {
if (event.key == "Escape") {
event.currentTarget.querySelector("a[name=cancel]").click();
}
}
var lastEnterTime;
function dragStart(event) {
lastEnterTime = event.timeStamp;
var row = event.currentTarget;
row.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden");
});
event.dataTransfer.setData("text/plain", row.getAttribute("data-drag-path"));
var rowRectangle = row.getBoundingClientRect();
event.dataTransfer.setDragImage(row, event.x - rowRectangle.left, event.y - rowRectangle.top);
event.dataTransfer.dropEffect = "move";
}
/*
* Drag tracking assumptions (based on FF 122.0 experience):
* * Enter/Leave events at the same timeStamp may not be logically ordered
* (e.g. E -> E -> L, not E -> L -> E),
* * not every Enter event has corresponding Leave event, especially during
* rapid pointer moves
* NOTE: sometimes Leave is not emitted when pointer goes fast over table
* and outside. This should probably be fixed in browser, than patched here.
*/
function dragEnter(event) {
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id);
dragLeave(event);
lastEnterTime = event.timeStamp;
const id = event.currentTarget.getAttribute("data-drop-id");
document.getElementById(id).classList.add("dropzone");
}
function dragOver(event) {
event.preventDefault();
}
function dragLeave(event) {
//console.log(event.timeStamp + " " + event.type + ": " + event.currentTarget.id);
// Leave has been accounted for by Enter at the same timestamp, processed earlier
if (event.timeStamp <= lastEnterTime) return;
event.currentTarget.closest("table").querySelectorAll(".dropzone").forEach((tr) => {
tr.classList.remove("dropzone");
})
}
function dragEnd(event) {
dragLeave(event);
event.currentTarget.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden");
});
}
function drop(event) {
event.preventDefault();
var params = new URLSearchParams();
var base_id = event.currentTarget.getAttribute("data-drop-id").split("_").pop();
params.append("unit[base_id]", base_id);
fetch(event.dataTransfer.getData("text/plain"), {
body: params,
headers: {
"Accept": "text/vnd.turbo-stream.html",
"X-CSRF-Token": document.head.querySelector("meta[name=csrf-token]").content,
"X-Requested-With": "XMLHttpRequest"
},
method: "POST"
})
.then(response => response.text())
.then(html => Turbo.renderStreamMessage(html))
}
<% end %>

View File

@@ -1,20 +1,9 @@
<% dom_obj = @unit.base || @unit %>
<% ids = {row: dom_id(dom_obj, :new),
hidden_row: nil,
link: dom_id(dom_obj, :new, :link),
form_tag: dom_id(dom_obj, :new, :form)} %>
<% link_id = dom_id(@unit.base || @unit, :add) %>
<%= turbo_stream.disable link_id -%>
<%= turbo_stream.disable ids[:link] -%>
<%= turbo_stream.append :unit_form do %>
<%- tabular_form_with model: @unit, html: {id: ids[:form_tag]} do |form| %>
<%= form.hidden_field :base_id if @unit.base_id? %>
<%= turbo_stream.replace :unit_form do %>
<%= form_with model: @unit, html: {id: :unit_form} do %>
<% end %>
<% end %>
<% if @unit.base_id? %>
<%= turbo_stream.remove ids[:row] %>
<%= turbo_stream.after @unit.base, partial: 'form', locals: ids %>
<% else %>
<%= turbo_stream.prepend :units, partial: 'form', locals: ids %>
<% end %>
<%= turbo_stream.insert_form (@unit.base || :units), partial: 'form', locals: {link_id: link_id} %>

View File

@@ -1,4 +1 @@
<%= turbo_stream.remove @unit %>
<%= turbo_stream.replace @previous_base if @previous_base %>
<%= turbo_stream.replace @unit.base if @unit.base_id? && (@previous_base.id != @unit.base_id) %>
<%= @before ? turbo_stream.before(@before, @unit) : turbo_stream.append(:units, @unit) %>
<% render_errors @unit %>

View File

@@ -1,3 +0,0 @@
<%= turbo_stream.close_form dom_id(@unit, :edit) %>
<%= turbo_stream.replace @unit.base if @unit.base_id? %>
<%= turbo_stream.replace @unit %>

View File

@@ -1 +0,0 @@
<% flash.discard %>

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
<p>Hello <%= @resource %>!</p>
<p>Hello <%= @resource.email %>!</p>
<p>We're contacting you to notify you that your password has been changed.</p>

View File

@@ -1,4 +1,4 @@
<p>Hello <%= @resource %>!</p>
<p>Hello <%= @resource.email %>!</p>
<p>Someone has requested a link to change your password. You can do this through the link below.</p>

View File

@@ -1,4 +1,4 @@
<p>Hello <%= @resource %>!</p>
<p>Hello <%= @resource.email %>!</p>
<p>Your account has been locked due to an excessive number of unsuccessful sign in attempts.</p>

View File

@@ -1,2 +0,0 @@
<%# For some reason flash messages are duplicated in bot flash and flash.now %>
<% flash.discard %>

View File

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

View File

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

View File

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

View File

@@ -1,16 +0,0 @@
<%= labeled_form_for resource, url: user_registration_path,
html: {class: 'main-area', onsubmit: 'formValidate(event)'} do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true,
autocomplete: 'email' %>
<%= f.password_field :password, required: true, size: 30,
minlength: @minimum_password_length, autocomplete: 'new-password' %>
<%= f.password_field :password_confirmation, required: true, size: 30,
minlength: @minimum_password_length, autocomplete: 'off' %>
<%= f.submit t(:register), data: {turbo: false} %>
<%= image_button_tag t(:resend_confirmation), 'email-sync-outline',
class: 'auxiliary', formaction: user_confirmation_path, formnovalidate: true,
data: {validate: f.field_id(:email)} %>
<% end %>

View File

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

View File

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

View File

@@ -1,19 +1,17 @@
<%= labeled_form_for resource, url: user_session_path,
html: {class: 'main-area', onsubmit: 'formValidate(event)'} do |f| %>
<div class="main">
<%= labelled_form_for resource, url: user_session_path do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, autocomplete: "email" %>
<%= f.password_field :password, required: true, size: 30, minlength: @minimum_password_length,
autocomplete: "current-password" %>
<%= f.email_field :email, required: true, size: 30, autofocus: true,
autocomplete: 'email' %>
<%= f.password_field :password, required: true, size: 30,
autocomplete: 'current-password' %>
<% if devise_mapping.rememberable? %>
<%= f.check_box :remember_me, label: t(".remember_me") %>
<% end %>
<% if devise_mapping.rememberable? %>
<%= f.check_box :remember_me %>
<%= f.submit t(:sign_in) %>
<% end %>
<%# /sign_in as HTML; /password as TURBO_STREAM %>
<%= f.submit t(:sign_in), data: {turbo: false} %>
<%= image_button_tag t(:recover_password), 'lock-reset', class: 'auxiliary',
formaction: user_password_path, formnovalidate: true,
data: {validate: f.field_id(:email)} %>
<% end %>
<%= content_tag :p, t(:or), style: "text-align: center;" %>
<%= image_link_to t(:recover_password), 'lock-reset', new_user_password_path,
class: 'centered' %>
</div>

View File

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

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 %>

View File

@@ -1,16 +0,0 @@
[Unit]
Description=fixin.me Rails Application
After=network.target
[Service]
Type=simple
User=USER
WorkingDirectory=PATH_TO_APP_DIRECTORY
Environment="RAILS_ENV=production"
Environment="RAILS_SERVE_STATIC_FILES=true"
ExecStart=/bin/bash -lc 'bundle exec rails s -e production'
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -23,10 +23,6 @@ module FixinMe
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.0
# 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.
#
# These settings can be overridden in specific environments using the files
@@ -36,7 +32,7 @@ module FixinMe
# config.eager_load_paths << Rails.root.join("extras")
config.action_dispatch.rescue_responses['ApplicationController::AccessForbidden'] = :forbidden
config.action_dispatch.rescue_responses['ApplicationController::ParameterInvalid'] = :unprocessable_entity
config.action_dispatch.rescue_responses['ArgumentError'] = :bad_request
# SETUP: Below settings need to be updated on a per-installation basis.
#
@@ -47,12 +43,9 @@ module FixinMe
# List of hosts this app is available at.
# https://guides.rubyonrails.org/configuring.html#actiondispatch-hostauthorization
config.hosts += ['localhost', 'example.com', IPAddr.new('1.2.3.4/32')]
config.hosts << 'localhost'
# Email address of admin account
config.admin = 'admin@localhost'
# Sender address of account registration-related messages
Devise.mailer_sender = 'noreply@localhost'
end
end

View File

@@ -2,7 +2,7 @@
# in your source code, provide the password or a full connection URL as an
# environment variable when you boot the app. For example:
#
# DATABASE_PASSWORD="Some-password1%"
# DATABASE_PASSWORD="some-password"
#
# or
#
@@ -26,25 +26,24 @@
default: &default
adapter: mysql2
encoding: utf8mb4
collation: utf8mb4_0900_as_ci
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
username: fixinme
password: Some-password1%
password:
socket: /run/mysqld/mysqld.sock
production:
<<: *default
database: fixinme
database: fixinme_production
# Unless you're planning on developing the application, you can skip
# Unless you're planning on developing the application, you can skip/remove
# configurations for development and test databases altogether.
#development:
# <<: *default
# database: fixinme_dev
development:
<<: *default
database: fixinme_dev
# 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:
# <<: *default
# database: fixinme_test
test:
<<: *default
database: fixinme_test

View File

@@ -1,26 +0,0 @@
require 'core_ext/array_delete_bang'
require 'core_ext/big_decimal_scientific_notation'
ActiveSupport.on_load :action_dispatch_system_test_case do
prepend CoreExt::ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelperUniqueId
end
ActiveSupport.on_load :action_view do
ActionView::RecordIdentifier.prepend CoreExt::ActionView::RecordIdentifierWithSuffix
end
ActiveSupport.on_load :active_record do
ActiveModel::Validations::NumericalityValidator
.prepend CoreExt::ActiveModel::Validations::NumericalityValidatesPrecisionAndScale
# Temporary patch for https://github.com/rails/rails/pull/54658
Arel::TreeManager::StatementMethods
.prepend CoreExt::Arel::TreeManager::StatementMethodsCteUpdateAndDelete
Arel::Nodes::DeleteStatement
.prepend CoreExt::Arel::Nodes::DeleteStatementCteUpdateAndDelete
Arel::Nodes::UpdateStatement
.prepend CoreExt::Arel::Nodes::UpdateStatementCteUpdateAndDelete
Arel::Visitors::ToSql.prepend CoreExt::Arel::Visitors::ToSqlCteUpdateAndDelete
Arel::Crud.prepend CoreExt::Arel::CrudCteUpdateAndDelete
Arel::SelectManager.prepend CoreExt::Arel::SelectManagerCteUpdateAndDelete
end

View File

@@ -24,8 +24,7 @@ Devise.setup do |config|
# Configure the e-mail address which will be shown in Devise::Mailer,
# note that it will be overwritten if you use your own mailer class
# with default "from" parameter.
# This is set in 'config/application.rb'.
#config.mailer_sender = 'fixinme@noreply.me'
config.mailer_sender = 'fixinme@noreply.me'
# Configure the class responsible to send e-mails.
# config.mailer = 'Devise::Mailer'
@@ -91,7 +90,7 @@ Devise.setup do |config|
# It will change confirmation, password recovery and other workflows
# to behave the same regardless if the e-mail provided was right or wrong.
# Does not affect registerable.
config.paranoid = true
# config.paranoid = true
# By default Devise will store the user in session. You can skip storage for
# particular strategies by setting this option.

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