144 Commits

Author SHA1 Message Date
46dd480b4e Alternative new Measurement form, WIP 2025-08-15 23:26:57 +02:00
0fb7f9946a Create alternative, spreadsheet-like style 2025-07-29 15:43:49 +02:00
d9bd2b46e3 Fix svg_tag display on Users tab 2025-07-26 23:24:54 +02:00
3aaf06806f Update Contributing section regarding gems and assets 2025-07-26 00:24:23 +02:00
ef0f3cbae3 Revert to asset precompilation in production 2025-07-26 00:10:59 +02:00
eb2d45ddc2 Draft auto-installation of database adapter gems 2025-07-25 15:32:31 +02:00
ae5bad89ac Update db schema 2025-07-25 15:31:41 +02:00
da38d8b585 Create Devise routes only when 'users' table exists
Closes #42
2025-07-25 15:30:26 +02:00
magicfixin
5ffc6974f0 Improve installation process 2025-07-23 23:42:41 +02:00
e12369cea1 Measurements#new form improvements 2025-05-13 22:30:58 +02:00
ef3484dfdf Update Measurements#new form pathnames on actions 2025-05-07 00:24:05 +02:00
9dbcfddf98 Merge corrections provided by Bambuch, cont. 2025-04-26 19:12:56 +02:00
cd9a64b5ad Merge corrections provided by Bambuch 2025-04-26 18:32:45 +02:00
f7e4fe2d38 Ignore .mysql_history 2025-04-25 14:27:53 +02:00
a1e17aee85 bundle update 2025-04-25 14:27:01 +02:00
e8a0768d97 Update Quantity.depth using WITH + UPDATE ALL 2025-04-25 14:25:13 +02:00
d1593df0e0 Use callbacks instead of attribute methods to update cached values 2025-04-24 18:59:36 +02:00
fe66522c21 Cached attribute definition (attr_cached) 2025-04-19 20:29:29 +02:00
1cddc794d2 Cleanup Quantity :pathname related code 2025-03-23 13:11:14 +01:00
3b30e58ff3 Persist Quantity :pathname 2025-03-23 12:56:57 +01:00
4c867daabb Patch ActiveRecord with PR 54658 2025-03-22 14:14:42 +01:00
8401424efa Partial refresh of Measurements#new form 2025-02-18 18:27:47 +01:00
c48bf290fd Implement Measurements#new 2025-02-18 11:25:32 +01:00
2f3c0e40a6 bundle update 2025-02-13 22:16:59 +01:00
72515b2aa2 Relax Ruby version requirement 2025-02-13 22:16:35 +01:00
9d60eee16b Add Measurements tab and #new form 2025-01-25 16:34:04 +01:00
3d7daa8944 Create Readout model 2025-01-22 00:14:57 +01:00
6300273186 Cleanup 2025-01-16 21:37:58 +01:00
2fdd770457 Center images in 'icon only' table columns 2025-01-16 21:29:10 +01:00
dada29d5e6 Fix table row indentation for hierarchy
Closes #57
2025-01-16 21:19:01 +01:00
5fff9adf4d Delete Units respecting foreign key constraint 2025-01-16 20:46:22 +01:00
7962cdf169 Simplify object and association checks
* check for <object> instead of <object>.nil?
* check for <association>_id? instead of <association>.nil? (avoids
  record loading)
2025-01-16 20:42:18 +01:00
c908063212 Persist Quantity :depth instead of computing it on the fly 2025-01-16 17:14:52 +01:00
30686dd1fc Cleanup of hierarchy related methods 2025-01-16 03:24:43 +01:00
cb69629276 Use associations in finders 2025-01-15 14:06:00 +01:00
2c0ae1530a Turn #ancestors into #with_ancestors scope 2025-01-14 23:27:32 +01:00
27038a74d0 Return Quantity progenies as a result of partial ordering
Separate scope only provided some optimisation by reducing the count of
records :numbered and should be unnecessary in future once numbering
can be merged with ordering into one recursive query
2025-01-14 23:25:26 +01:00
201359de3e Allow to reparent Quantity everywhere
Closes #61
2025-01-14 21:13:35 +01:00
8524beefdc Allow to drag all Quantities 2025-01-14 15:03:25 +01:00
644d1f4b9a Add Quantity #reparent action 2025-01-14 15:02:38 +01:00
4b453c1a82 Add :progenies as a scope to list ordered descendants of Quantity 2025-01-14 01:01:06 +01:00
2c0d5af022 Merge Unit and Quantity drag&drop js
Closes #55
2025-01-13 02:23:51 +01:00
0652d4a89b Disallow self- and descendant-reference for base/parent 2025-01-12 19:15:43 +01:00
17b4e4f8a7 Don't duplicate ancestors search in #successive 2025-01-12 18:11:37 +01:00
421515e5ce ancestors() sets depth for self, instead of returning new instance 2025-01-12 18:09:34 +01:00
d5e7ccacf5 Add Quantity #edit and #update 2025-01-11 22:51:49 +01:00
adcc6699ce Avoid N+1 queries on index 2025-01-11 21:58:12 +01:00
0dc683da4f Initialize units hash before use 2025-01-11 21:53:02 +01:00
b6acb30785 Make Quantity name unique among siblings
Remove Quantity domain - will be replaced by configurable per-domain
root Quantity, limiting selection to descendants only
2025-01-11 21:50:36 +01:00
57f10c94a4 Add Quantity #new, #create, #destroy actions 2025-01-11 17:01:34 +01:00
1e7ef75e8b Include self and ancestors in result 2025-01-09 15:59:18 +01:00
c7f16514d2 Re-format query 2025-01-09 15:58:21 +01:00
23e2f6a062 Move :base_id hidden field to form tag 2025-01-08 14:36:32 +01:00
9461c1f979 Update #successive for unlimited depth hierarchy 2025-01-08 14:29:49 +01:00
fa7918f0e3 Set row indentation based on depth 2025-01-08 13:55:40 +01:00
0d8e7c6c0e Unlimited depth hierarchy ordering 2025-01-08 02:18:38 +01:00
3788f1a749 Fix db:seed:export task
* replace variable names with Hash to avoid invalid Ruby identifiers
* export all values as single-quoted to avoid string interpolation and
  treating BigDecimal numbers as Float
* #truncate table instead of #delete_all to avoid foreing_key
  constraints errors

Closes #56
2025-01-05 21:05:47 +01:00
aa862f0e90 Quantities index WIP 2025-01-05 20:47:49 +01:00
b3aea97087 Add Quantities tab 2025-01-05 03:18:39 +01:00
141d67ad21 Add Quantities table 2025-01-05 00:44:02 +01:00
d86e38a3ec Hide input[type=number] spin buttons and fix text alignment 2025-01-04 16:54:27 +01:00
ed0234f158 Allow only positive Unit multiplier
Closes #51
2025-01-04 15:32:07 +01:00
b133b2be7c Add seperate 'no items' message for defaults
Closes #53
2025-01-03 18:47:56 +01:00
d250119601 Fix tests after routing/locale changes 2025-01-03 15:04:27 +01:00
af152c5e8b Disable links instead of hiding
Closes #49
2025-01-03 14:41:18 +01:00
80f05ba45f Change root routes to redirects 2025-01-02 19:29:44 +01:00
42aaf39f14 Remove unused turbo_stream index 2025-01-02 19:21:01 +01:00
5dd3303019 Disable Turbo in User forms/links 2025-01-02 19:20:08 +01:00
afc4f5cee0 Hide page only during tests 2025-01-02 19:04:14 +01:00
ef825728ac Set single quotes for direct routes 2025-01-02 15:23:22 +01:00
b8bcbee1e9 Separate root routes for un- and authenticated users
Closes #34
Closes #47
2025-01-02 15:19:33 +01:00
8b1a186467 Disable Turbo prefetch 2025-01-02 03:01:30 +01:00
fd4c7259b0 Move navigation tab labels 2025-01-02 02:48:33 +01:00
a6e3833fd0 Extend custom FormBuilder to DRY in forms
Closes #8
Closes #45
2025-01-01 16:26:58 +01:00
3379794c6b Use variables for remaining colors 2024-12-31 19:29:01 +01:00
c8c8d8cd70 Hide actions for restricted users
Closes #43
2024-12-30 00:44:21 +01:00
b9d979ad0c Fix tests 2024-12-30 00:19:16 +01:00
d9cc1a977b Ignore Vim session 2024-12-30 00:14:30 +01:00
8552571526 Disable Unit destuction for base units with subunits
Closes #40
2024-12-28 23:57:47 +01:00
bd40727231 Focus forms properly on open and close
Closes #44
2024-12-28 15:16:37 +01:00
7759021ba1 Make inputs and buttons in table form equal height
Closes #38
2024-12-24 02:02:10 +01:00
a9307ad455 Form uses button instead of input to display SVG 2024-12-23 00:47:44 +01:00
8f85432982 Silence empty tests 2024-12-22 01:33:39 +01:00
0659c1e1c1 Don't render index on Unit actions 2024-12-22 01:31:14 +01:00
d726e92445 Allow opening multiple new/edit forms
Closes #30
2024-12-21 17:52:45 +01:00
e5cf3dc0ae Avoid refreshing whole index on create 2024-12-19 03:46:29 +01:00
55b6ff3248 Find successive Unit according to sort order
This will allow to insert Units into table without refresh of all
items/removal of forms
2024-12-19 03:42:28 +01:00
ba8ac5d2fa Remove unnecessary link_id 2024-12-17 02:44:52 +01:00
f0dab7a5f9 Fix Units new/edit display on validation errors
Add test_new_and_edit_on_validation_error
Closes #41
2024-12-17 01:53:25 +01:00
f472526aa8 Change variable name 2024-12-17 00:14:34 +01:00
e15b983b56 Avoid duplicated symbols, check created record attributes 2024-12-15 23:36:24 +01:00
30ee4a861e Define LINK_LABELS once. Generate Unicode random strings. 2024-12-14 19:40:01 +01:00
dcffa86e93 Rename add -> new in test descriptions 2024-12-12 00:53:23 +01:00
d5719b1e9d Fill multiplier field, confirm Add button disabled 2024-12-12 00:44:26 +01:00
3a25c1dbd0 Equally sample add unit/add subunit/edit links for test 2024-12-12 00:35:11 +01:00
bb4fbb3adc Refine "add and edit disallow opening multiple forms" test 2024-12-10 18:11:13 +01:00
dc92a333be Fix UnitsTest#test_add_unit 2024-12-10 01:18:27 +01:00
2bbf62b84b Update gems 2024-12-09 20:01:04 +01:00
f3f0b9dc9e Fix UnitsiTest#test_index 2024-12-09 20:00:14 +01:00
40808639cc Update tests to new schema 2024-12-08 15:29:01 +01:00
a18f257378 Schema update 2024-12-08 15:28:38 +01:00
e49eac766c Add decimal type requirements 2024-12-08 14:25:06 +01:00
0b201606c2 Replace #columns_hash with #type_for_attribute for limits 2024-12-08 13:47:30 +01:00
15a5515c99 Extend NumericalityValidator to check precision and scale
Use new checks on Unit.multiplier
Closes #28
2024-12-07 20:41:19 +01:00
25ac126df9 Systematize core extesions 2024-12-07 16:05:07 +01:00
d31ff5442a Update messages for empty Unit/default Unit indexes
Closes #26
2024-12-06 15:29:03 +01:00
7e5f873cde Change helper into BigDecimal method 2024-12-06 15:24:25 +01:00
2e4eb3d4b5 Rake task to export default settings as seeds 2024-12-06 01:17:05 +01:00
b38d72e9b0 Return to per-action permission filters 2024-11-30 20:15:30 +01:00
13685aa476 Update error handling according to new rules 2024-11-30 16:28:43 +01:00
2cbae12fa2 Implement Units default destroy 2024-11-30 16:11:31 +01:00
1fedd70fe5 Fix defaults listing
Base symbol was displayed twice when it existed as default and
non-default and both of them had at least one subunit.
Also: sorting by base_id yielded non-alphabetic order in such case.
2024-11-27 19:54:50 +01:00
f9bd81c6ab Implement Unit defaults export
Disable import_all until implemented
2024-11-26 02:31:25 +01:00
3711251656 Unit: limit symbol length, change name:string -> description:text
Closes #11
Closes #12
2024-11-24 15:13:59 +01:00
d6fdff252a Validate User 'email' and 'unconfirmed_email' lengths
Closes #6
2024-11-24 14:11:54 +01:00
f3751c5fa1 Disallow NULLs 2024-11-23 23:50:57 +01:00
f4ca1e91fa Mark redirecting buttons with trailing '...'
Closes #2
2024-11-23 23:26:08 +01:00
e75391ae18 Display User name using #to_s 2024-11-23 14:55:29 +01:00
76ce2eeedd Display Unit name using #to_s 2024-11-23 02:24:08 +01:00
bdc4ec4644 Specify user modifiable ATTRIBUTES 2024-11-22 15:48:09 +01:00
1d439928e2 Import with proper base 2024-11-22 15:18:27 +01:00
e157c17e0e Hide 'Import all' button until implemented 2024-11-22 15:03:33 +01:00
279f9bd6ac Display Defaults hierarchy including same base Units 2024-11-22 03:10:08 +01:00
6c678b6560 defaults_diff returns base Units where needed 2024-11-21 01:50:29 +01:00
1790f2e7f2 Rails upgrade to 7.2.2 to enable recursive CTEs 2024-11-19 00:00:18 +01:00
f0e28deea2 Implement 'import' action 2024-11-17 03:39:39 +01:00
41982e9dbc Import portability checks complete 2024-11-16 02:31:53 +01:00
d9e74ed305 Avoid unscoping 2024-11-15 19:45:55 +01:00
4447735dce First part of portability checks 2024-11-15 02:02:19 +01:00
a01c89ce3a Further simplify EXISTS condition with SelectManager 2024-11-14 04:28:20 +01:00
7234d60afc Replace additional joins with NOT EXISTS subquery 2024-11-14 02:50:53 +01:00
4c09989788 Add translation 2024-11-11 15:43:20 +01:00
9a02f0b0ae Fix quotes and translations 2024-11-10 21:46:00 +01:00
51011951f9 Default Units index 2024-11-10 21:30:19 +01:00
aebbe11bef Add default Units actions 2024-11-10 17:34:43 +01:00
817b1a4376 Update permission checking 2024-11-10 17:34:02 +01:00
537cd18336 Change namespace for defaults controllers
To allow proper path prefix for view partials when using
config.action_view.prefix_partial_path_with_controller_namespace
2024-11-09 21:50:50 +01:00
f402e6ba00 Add source of icons 2024-11-09 15:58:01 +01:00
f899fed910 Prefer icons without circle 2024-11-09 15:56:22 +01:00
846eb6da14 Preliminary support for default Units import 2024-11-09 02:05:04 +01:00
be48d6fd7f Use Arel::FactoryMehods for coalesce() 2024-11-09 02:03:34 +01:00
c6a7838df1 Change 'Back' button to tab 2024-11-09 02:02:01 +01:00
769e4af603 Bring per-installation setting to application.rb 2024-06-04 00:47:50 +02:00
e905910719 Import previously missed settings to *.dist 2024-06-04 00:45:33 +02:00
115 changed files with 2297 additions and 730 deletions

11
.gitignore vendored
View File

@@ -3,10 +3,15 @@
/.cache
/.gem
# Ignore master key for decrypting credentials and more.
# 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).
/config/application.rb
/config/credentials.yml.enc
/config/database.yml
/config/initializers/secret_token.rb
/config/master.key
/config/puma.rb
@@ -32,8 +37,10 @@
/.irb_history
/.lesshst
/.local
/.mysql_history
/.ssh
/.vim
/.viminfo
/.webdrivers
*.swp
Session.vim

View File

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

24
Gemfile
View File

@@ -1,20 +1,30 @@
source "https://rubygems.org"
ruby file: ".ruby-version"
gem "rails", "~> 7.1.2"
# The requirement for the Ruby version comes from Rails
gem "rails", "~> 7.2.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'
# turborails >= 2.0.0 required with npm v8.0.0 with support for [autofocus]
# attribute in turbo-streams
gem 'turbo-rails', '> 1.5.0'
gem "importmap-rails"
gem "turbo-rails", "~> 2.0"
group :development, :test do
gem "byebug"

View File

@@ -1,124 +1,130 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (7.1.3)
actionpack (= 7.1.3)
activesupport (= 7.1.3)
actioncable (7.2.2.1)
actionpack (= 7.2.2.1)
activesupport (= 7.2.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
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
actionmailbox (7.2.2.1)
actionpack (= 7.2.2.1)
activejob (= 7.2.2.1)
activerecord (= 7.2.2.1)
activestorage (= 7.2.2.1)
activesupport (= 7.2.2.1)
mail (>= 2.8.0)
actionmailer (7.2.2.1)
actionpack (= 7.2.2.1)
actionview (= 7.2.2.1)
activejob (= 7.2.2.1)
activesupport (= 7.2.2.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.1.3)
actionview (= 7.1.3)
activesupport (= 7.1.3)
actionpack (7.2.2.1)
actionview (= 7.2.2.1)
activesupport (= 7.2.2.1)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
rack (>= 2.2.4, < 3.2)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
actiontext (7.1.3)
actionpack (= 7.1.3)
activerecord (= 7.1.3)
activestorage (= 7.1.3)
activesupport (= 7.1.3)
useragent (~> 0.16)
actiontext (7.2.2.1)
actionpack (= 7.2.2.1)
activerecord (= 7.2.2.1)
activestorage (= 7.2.2.1)
activesupport (= 7.2.2.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.1.3)
activesupport (= 7.1.3)
actionview (7.2.2.1)
activesupport (= 7.2.2.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.1.3)
activesupport (= 7.1.3)
activejob (7.2.2.1)
activesupport (= 7.2.2.1)
globalid (>= 0.3.6)
activemodel (7.1.3)
activesupport (= 7.1.3)
activerecord (7.1.3)
activemodel (= 7.1.3)
activesupport (= 7.1.3)
activemodel (7.2.2.1)
activesupport (= 7.2.2.1)
activerecord (7.2.2.1)
activemodel (= 7.2.2.1)
activesupport (= 7.2.2.1)
timeout (>= 0.4.0)
activestorage (7.1.3)
actionpack (= 7.1.3)
activejob (= 7.1.3)
activerecord (= 7.1.3)
activesupport (= 7.1.3)
activestorage (7.2.2.1)
actionpack (= 7.2.2.1)
activejob (= 7.2.2.1)
activerecord (= 7.2.2.1)
activesupport (= 7.2.2.1)
marcel (~> 1.0)
activesupport (7.1.3)
activesupport (7.2.2.1)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
base64 (0.2.0)
bcrypt (3.1.20)
bigdecimal (3.1.6)
benchmark (0.4.0)
bigdecimal (3.1.9)
bindex (0.8.1)
builder (3.2.4)
byebug (11.1.3)
capybara (3.39.2)
builder (3.3.0)
byebug (12.0.0)
capybara (3.40.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
nokogiri (~> 1.11)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
concurrent-ruby (1.2.3)
connection_pool (2.4.1)
concurrent-ruby (1.3.5)
connection_pool (2.5.2)
crass (1.0.6)
date (3.3.4)
devise (4.9.3)
date (3.4.1)
devise (4.9.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
drb (2.2.0)
ruby2_keywords
erubi (1.12.0)
ffi (1.16.3)
drb (2.2.1)
erubi (1.13.1)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu)
ffi (1.17.2-arm-linux-musl)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl)
globalid (1.2.1)
activesupport (>= 6.1)
i18n (1.14.1)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
importmap-rails (2.0.1)
importmap-rails (2.1.0)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.7.2)
irb (1.11.1)
rdoc
io-console (0.8.0)
irb (1.15.2)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
loofah (2.22.0)
logger (1.7.0)
loofah (2.24.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -126,80 +132,97 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.0.2)
marcel (1.0.4)
matrix (0.4.2)
mini_mime (1.1.5)
minitest (5.21.2)
mutex_m (0.2.0)
mysql2 (0.5.5)
net-imap (0.4.9.1)
minitest (5.25.5)
mysql2 (0.5.6)
net-imap (0.5.7)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.4.0.1)
net-smtp (0.5.1)
net-protocol
nio4r (2.7.0)
nokogiri (1.16.0-x86_64-linux)
nio4r (2.7.4)
nokogiri (1.18.8-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.8-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.8-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.8-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.8-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.8-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-musl)
racc (~> 1.4)
orm_adapter (0.5.0)
psych (5.1.2)
pg (1.5.9)
pp (0.6.2)
prettyprint
prettyprint (0.2.0)
psych (5.2.3)
date
stringio
public_suffix (5.0.4)
puma (6.4.2)
public_suffix (6.0.1)
puma (6.6.0)
nio4r (~> 2.0)
racc (1.7.3)
rack (3.0.8)
rack-session (2.0.0)
racc (1.8.1)
rack (3.1.13)
rack-session (2.1.0)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.1.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.1.0)
rackup (2.2.1)
rack (>= 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)
rails (7.2.2.1)
actioncable (= 7.2.2.1)
actionmailbox (= 7.2.2.1)
actionmailer (= 7.2.2.1)
actionpack (= 7.2.2.1)
actiontext (= 7.2.2.1)
actionview (= 7.2.2.1)
activejob (= 7.2.2.1)
activemodel (= 7.2.2.1)
activerecord (= 7.2.2.1)
activestorage (= 7.2.2.1)
activesupport (= 7.2.2.1)
bundler (>= 1.15.0)
railties (= 7.1.3)
railties (= 7.2.2.1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.1.3)
actionpack (= 7.1.3)
activesupport (= 7.1.3)
irb
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.2.2.1)
actionpack (= 7.2.2.1)
activesupport (= 7.2.2.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
rake (13.1.0)
rdoc (6.6.2)
rake (13.2.1)
rdoc (6.13.1)
psych (>= 4.0.0)
regexp_parser (2.9.0)
reline (0.4.2)
regexp_parser (2.10.0)
reline (0.6.1)
io-console (~> 0.5)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
rexml (3.2.6)
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
rexml (3.4.1)
rubyzip (2.4.1)
sassc (2.4.0)
ffi (~> 1.9)
sassc-rails (2.1.2)
@@ -208,27 +231,39 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
selenium-webdriver (4.16.0)
securerandom (0.4.1)
selenium-webdriver (4.31.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sprockets (4.2.1)
sprockets (4.2.2)
concurrent-ruby (~> 1.0)
logger
rack (>= 2.2.4, < 4)
sprockets-rails (3.4.2)
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets-rails (3.5.2)
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.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)
sqlite3 (2.7.3-aarch64-linux-gnu)
sqlite3 (2.7.3-aarch64-linux-musl)
sqlite3 (2.7.3-arm-linux-gnu)
sqlite3 (2.7.3-arm-linux-musl)
sqlite3 (2.7.3-arm64-darwin)
sqlite3 (2.7.3-x86_64-darwin)
sqlite3 (2.7.3-x86_64-linux-gnu)
sqlite3 (2.7.3-x86_64-linux-musl)
stringio (3.1.7)
thor (1.3.2)
tilt (2.6.0)
timeout (0.4.3)
turbo-rails (2.0.13)
actionpack (>= 7.1.0)
railties (>= 7.1.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)
@@ -236,17 +271,24 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webrick (1.8.1)
websocket (1.2.10)
websocket-driver (0.7.6)
websocket (1.2.11)
websocket-driver (0.7.7)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.12)
zeitwerk (2.7.2)
PLATFORMS
x86_64-linux
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
DEPENDENCIES
byebug
@@ -254,14 +296,16 @@ DEPENDENCIES
devise
importmap-rails
mysql2 (~> 0.5)
pg (~> 1.5)
puma (~> 6.0)
rails (~> 7.1.2)
rails (~> 7.2.2)
sassc-rails
selenium-webdriver
sprockets-rails
turbo-rails (> 1.5.0)
sqlite3 (~> 2.7)
turbo-rails (~> 2.0)
tzinfo-data
web-console
BUNDLED WITH
2.5.3
2.6.3

106
README.md
View File

@@ -4,44 +4,74 @@ README
Quantified self
Software requirements
---------------------
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
* Server side:
* Ruby version: developed on Ruby 3.x
* database with recursive Common Table Expressions (CTE) support, e.g.
MySQL >= 8.0, MariaDB >= 10.2.2
* 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
* for testing: browser as specified in _Client side_ requirements
* Client side:
* browser supporting below requirements (e.g. Firefox >= 121):
* browser (e.g. Firefox >= 121) supporting:
* [`import maps`](https://caniuse.com/import-maps)
(required by `importmap-rails` gem >= 2.0)
* CSS [`:has()` pseudo-class](https://caniuse.com/css-has)
### Gems
Installation
------------
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
git clone https://gitea.michalczyk.pro/fixin.me/fixin.me.git
bundle config set --local path '.gem'
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 install
### Configuration
Configuration
-------------
Customize application settings (starting below `SETUP` comment) appropriately:
cp -a config/application.rb.dist config/application.rb
Modify configuration settings below `SETUP` comment appropriately.
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:
bundle exec rails credentials:edit
Database
--------
Precompile assets:
RAILS_ENV=production bundle exec rails assets:precompile
### Database
Grant database user and privileges:
> mysql -p
mysql> create user fixinme@localhost identified by '<some password>';
mysql> create user fixinme@localhost identified by 'Some-password1%';
mysql> grant all privileges on fixinme.* to fixinme@localhost;
mysql> flush privileges;
@@ -51,7 +81,7 @@ Copy config template and update database configuration:
Run database creation and migration tasks:
RAILS_ENV="production" bundle exec rake db:create db:migrate db:seed
RAILS_ENV=production bundle exec rails db:create db:migrate db:seed
Running
@@ -59,7 +89,7 @@ Running
### Standalone Rails server + Apache proxy
Copy Puma config template:
Customize Puma config template:
cp -a config/puma.rb.dist config/puma.rb
@@ -67,10 +97,21 @@ and specify server IP/port, either with `port` or `bind`, e.g.:
bind 'tcp://0.0.0.0:3000'
Run server
#### (option 1) Start server manually
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
@@ -80,6 +121,21 @@ 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,
@@ -90,7 +146,6 @@ 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:
@@ -99,8 +154,7 @@ Starting application server in development environment:
For running rake tasks, prepend command with environment:
RAILS_ENV="development" bundle exec rake ...
RAILS_ENV=development bundle exec rails ...
### Running tests
@@ -118,3 +172,13 @@ 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

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

After

Width:  |  Height:  |  Size: 236 B

View File

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

After

Width:  |  Height:  |  Size: 330 B

View File

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

After

Width:  |  Height:  |  Size: 192 B

View File

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

After

Width:  |  Height:  |  Size: 174 B

View File

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

After

Width:  |  Height:  |  Size: 297 B

View File

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

After

Width:  |  Height:  |  Size: 197 B

View File

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

After

Width:  |  Height:  |  Size: 242 B

View File

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

After

Width:  |  Height:  |  Size: 423 B

View File

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

After

Width:  |  Height:  |  Size: 173 B

View File

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

After

Width:  |  Height:  |  Size: 475 B

View File

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

Before

Width:  |  Height:  |  Size: 537 B

View File

@@ -12,17 +12,9 @@
* 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
*/
/* Colors:
* dark blue: #006c9b
* light blue: #009ade
* dark red: #b21237
* light red: #ff1f5b
* */
:root {
--color-focus-gray: #f3f3f3;
--color-border-gray: #dddddd;
@@ -31,6 +23,13 @@
--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;
}
@@ -41,7 +40,7 @@
box-sizing: border-box;
}
::selection {
background-color: #009ade;
background-color: var(--color-blue);
color: white;
}
:focus-visible {
@@ -49,15 +48,18 @@
}
/* TODO: collapse gaps around empty rows (`topside`) once possible
* https://github.com/w3c/csswg-drafts/issues/5813 */
body {
display: grid;
gap: 0.8em;
grid-template-areas:
"header header header"
"nav nav nav"
"leftside main rightside";
"header header header"
"nav nav nav"
"leftempty topside rightempty"
"leftside main rightside";
grid-template-columns: 1fr auto 1fr;
grid-template-rows: repeat(3, auto);
grid-template-rows: repeat(4, auto);
font-family: system-ui;
margin: 0.4em;
}
@@ -68,6 +70,10 @@ textarea {
background-color: inherit;
font: inherit;
}
input,
select {
text-align: inherit;
}
/* blue - target for interaction with pointer */
@@ -80,9 +86,10 @@ input[type=submit] {
text-decoration: none;
white-space: nowrap;
}
/* [hidden] submit controls cannot have `display` set as it makes them visible */
.button,
button,
input[type=submit],
button:not([hidden]),
input[type=submit]:not([hidden]),
.tab {
align-items: center;
color: var(--color-gray);
@@ -98,23 +105,46 @@ input[type=submit] {
width: fit-content;
}
input:not([type=submit]):not([type=checkbox]),
select {
select,
textarea {
padding: 0.2em 0.4em;
}
.button,
button,
input,
select {
select,
textarea {
border: solid 1px var(--color-gray);
border-radius: 0.25em;
}
fieldset,
textarea {
margin: 0
}
.button > svg,
.tab > svg,
button > svg {
height: 1.8em;
padding-right: 0.4em;
width: 1.8em;
}
.button > svg:not(:last-child),
.tab > svg:not(:last-child),
button > svg:not(:last-child) {
padding-right: 0.4em;
}
fieldset {
padding: 0.4em;
}
legend {
color: var(--color-gray);
display: flex;
gap: 0.4em;
width: 100%;
}
legend span {
align-content: center;
flex-grow: 1;
}
/* TODO: move normal non-button links (<a>:hover/:focus) styling here (i.e.
* page-wide, top-level) and remove from table.items - as the style should be
@@ -127,18 +157,18 @@ input[type=submit]:focus-visible {
.button:hover,
button:hover,
input[type=submit]:hover {
background-color: #009ade;
border-color: #009ade;
background-color: var(--color-blue);
border-color: var(--color-blue);
color: white;
fill: white;
}
.dangerous:hover {
background-color: #ff1f5b;
border-color: #ff1f5b;
background-color: var(--color-red);
border-color: var(--color-red);
}
input[type=checkbox] {
accent-color: #009ade;
accent-color: var(--color-blue);
appearance: none;
-webkit-appearance: none;
display: flex;
@@ -150,30 +180,50 @@ input[type=checkbox]:checked {
appearance: checkbox;
-webkit-appearance: checkbox;
}
/* Hide spin buttons in input number fields */
input[type=number] {
appearance: textfield;
-moz-appearance: textfield;
}
input::-webkit-inner-spin-button {
-webkit-appearance: none;
}
input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input:hover,
select:hover {
border-color: #009ade;
outline: solid 1px #009ade;
select:hover,
textarea:hover {
border-color: var(--color-blue);
outline: solid 1px var(--color-blue);
}
input:invalid,
select:invalid,
textarea:invalid {
border-color: var(--color-red);
outline: solid 1px var(--color-red);
}
select:hover {
cursor: pointer;
}
input:focus-visible,
select:focus-within,
select:focus-visible {
accent-color: #006c9b;
select:focus-visible,
textarea:focus-visible {
accent-color: var(--color-dark-blue);
background-color: var(--color-focus-gray);
}
input[type=text]:read-only {
fieldset,
input[type=text]:read-only,
textarea:read-only {
border: none;
padding-left: 0;
padding-right: 0;
}
.header {
display: flex;
gap: 0.8em;
header {
grid-area: header;
}
@@ -197,12 +247,15 @@ input[type=text]:read-only {
background-color: var(--color-focus-gray);
}
.navigation > .tab.active {
border-bottom: solid 4px #009ade;
color: #009ade;
fill: #009ade;
border-bottom: solid 4px var(--color-blue);
color: var(--color-blue);
fill: var(--color-blue);
}
.topside {
grid-area: topside;
}
.leftside {
grid-area: leftside;
}
@@ -214,6 +267,18 @@ input[type=text]:read-only {
}
.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;
}
#flashes {
display: grid;
gap: 0.2em;
@@ -241,8 +306,8 @@ input[type=text]:read-only {
width: 1.4em;
}
.flash.alert {
border-color: #ff1f5b;
background-color: #ff1f5b;
border-color: var(--color-red);
background-color: var(--color-red);
}
.flash.notice:before {
content: url('pictograms/check-circle-outline.svg');
@@ -251,8 +316,8 @@ input[type=text]:read-only {
width: 1.4em;
}
.flash.notice {
border-color: #009ade;
background-color: #009ade;
border-color: var(--color-blue);
background-color: var(--color-blue);
}
.flash > div {
grid-column: 2;
@@ -289,7 +354,7 @@ form label.required {
}
form label.error,
form td.error::after {
color: #ff1f5b;
color: var(--color-red);
}
form td.error {
display: -webkit-box;
@@ -336,23 +401,9 @@ 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;
}
table.items td:last-child {
padding-inline-end: 0.1em;
}
table.items td {
border-top: solid 1px var(--color-border-gray);
min-height: 2.4em;
padding-block: 0.1em;
}
/* For <a> to fill <td> completely, we use an ::after pseudoelement. */
table.items td.link {
padding: 0 0 0 1em;
padding: 0;
position: relative;
}
table.items td.link a {
@@ -364,18 +415,34 @@ table.items td.link a::after {
inset: 0;
position: absolute;
}
table.items td.subunit {
padding-inline-start: 1.8em;
table.items td:first-child {
padding-inline-start: calc(1em + var(--depth) * 0.8em);
}
table.items td.subunit:has(input) {
padding-inline-start: calc(1.4em - 1px);
table.items td:has(input, textarea) {
padding-inline-start: calc(0.6em - 0.9px);
}
table.items td.actions {
align-items: center;
table.items td:first-child:has(input, textarea) {
padding-inline-start: calc(0.6em + var(--depth) * 0.8em - 0.9px);
}
table.items th:last-child {
padding-inline-end: 0.4em;
}
table.items td:last-child {
padding-inline-end: 0.1em;
}
table.items td {
border-top: solid 1px var(--color-border-gray);
height: 2.4em;
padding-block: 0.1em;
}
table.items .actions {
display: flex;
gap: 0.4em;
justify-content: end;
}
table.items .actions.centered {
justify-content: center;
}
table.items tr.dropzone {
position: relative;
}
@@ -383,13 +450,16 @@ table.items tr.dropzone::after {
content: '';
inset: 1px 0 0 0;
position: absolute;
outline: dashed 2px #009ade;
outline: dashed 2px var(--color-blue);
outline-offset: -1px;
z-index: var(--z-index-table-row-outline);
}
table.items td.handle {
cursor: move;
}
table.items tr.form td {
vertical-align: top;
}
/* TODO: replace :hover:focus-visible combos with proper LOVE stye order */
/* TODO: Update styling, including rem removal. */
@@ -401,16 +471,17 @@ table.items td.link a:hover:focus-visible {
text-underline-offset: 0.2rem;
}
table.items td.link a:hover {
color: #009ade;
color: var(--color-blue);
}
table.items td.link a:focus-visible {
text-decoration-color: var(--color-gray);
}
table.items td.link a:hover:focus-visible {
color: #006c9b;
color: var(--color-dark-blue);
}
table.items td:not(:first-child) {
table.items td:not(:first-child),
.grayed {
color: var(--color-table-gray);
fill: var(--color-table-gray);
}
@@ -425,6 +496,9 @@ table.items svg {
vertical-align: middle;
width: 1.2rem;
}
table.items td.svg {
text-align: center;
}
table.items td.number {
text-align: right;
}
@@ -434,12 +508,18 @@ table.items input[type=submit] {
font-weight: normal;
padding: 0.3em;
}
table.items input:not([type=submit]):not([type=checkbox]),
table.items select,
table.items textarea {
padding-block: 0.375em;
}
/* TODO: find a way (layers?) to style inputs differently while making sure
* hover works properly without using :not(:hover) selectors here. */
table.items .button:not(:hover),
table.items button:not(:hover),
table.items input:not(:hover),
table.items select:not(:hover) {
table.items select:not(:hover),
table.items textarea:not(:hover) {
border-color: var(--color-border-gray);
}
table.items .button:not(:hover),
@@ -453,6 +533,22 @@ table.items select:focus-visible {
color: black;
}
form a[name=cancel] {
border-color: var(--color-border-gray);
color: var(--color-nav-gray);
fill: var(--color-nav-gray);
}
form table.items {
border: none;
}
form table.items td {
border: none;
text-align: left;
vertical-align: middle;
}
form table.items td:first-child {
color: inherit;
}
.centered {
margin: 0 auto;
@@ -460,6 +556,15 @@ table.items select:focus-visible {
.extendedright {
margin-right: auto;
}
.hflex {
display: flex;
gap: 0.8em;
}
.vflex {
display: flex;
gap: 0.8em;
flex-direction: column;
}
[disabled] {
border-color: var(--color-border-gray) !important;
color: var(--color-border-gray) !important;
@@ -470,15 +575,3 @@ table.items select:focus-visible {
.unwrappable {
white-space: nowrap;
}
.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

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

View File

@@ -31,18 +31,6 @@ class ApplicationController < ActionController::Base
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,6 +43,11 @@ 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

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

@@ -0,0 +1,14 @@
class MeasurementsController < ApplicationController
def index
end
def new
@quantities = current_user.quantities.ordered
end
def create
end
def destroy
end
end

View File

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

@@ -0,0 +1,43 @@
class ReadoutsController < ApplicationController
before_action :find_quantity, only: [:new, :discard]
before_action :find_prev_quantities, only: [:new, :discard]
def new
new_quantities =
case params[:scope]
when 'children'
@quantity.subquantities
when 'subtree'
@quantity.progenies
else
[@quantity]
end
new_quantities -= @prev_quantities
@readouts = current_user.readouts.build(new_quantities.map { |q| {quantity: q} })
@user_quantities = current_user.quantities.ordered
@user_units = current_user.units.ordered
@quantities = @prev_quantities + new_quantities
# @common_ancestor = current_user.quantities
# .common_ancestors(all_quantities.map(&:parent_id)).first
end
def discard
@prev_quantities -= [@quantity]
@common_ancestor = current_user.quantities
.common_ancestors(@prev_quantities.map(&:parent_id)).first
end
private
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,10 +0,0 @@
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.includes(:subunits)
@units = current_user.units.ordered.includes(:base, :subunits)
end
def new
@@ -19,8 +19,8 @@ class UnitsController < ApplicationController
def create
@unit = current_user.units.new(unit_params)
if @unit.save
flash.now[:notice] = t(".success")
run_and_render :index
@before = @unit.successive
flash.now[:notice] = t('.success', unit: @unit)
else
render :new
end
@@ -31,37 +31,39 @@ class UnitsController < ApplicationController
def update
if @unit.update(unit_params.except(:base_id))
flash.now[:notice] = t(".success")
run_and_render :index
flash.now[:notice] = t('.success', unit: @unit)
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)
if permitted[:base_id].blank? && @unit.multiplier != 1
permitted.merge!(multiplier: 1)
flash.now[:notice] = t(".multiplier_reset", symbol: @unit.symbol)
end
permitted.merge!(multiplier: 1) if permitted[:base_id].blank? && @unit.multiplier != 1
run_and_render :index if @unit.update(permitted)
@previous_base = @unit.base
@unit.update!(permitted)
@before = @unit.successive
if @unit.multiplier_previously_changed?
flash.now[:notice] = t(".multiplier_reset", unit: @unit)
end
end
def destroy
if @unit.destroy
flash.now[:notice] = t(".success")
end
run_and_render :index
@unit.destroy!
flash.now[:notice] = t('.success', unit: @unit)
end
private
def unit_params
params.require(:unit).permit(:symbol, :name, :base_id, :multiplier)
params.require(:unit).permit(Unit::ATTRIBUTES)
end
def find_unit
@unit = Unit.find_by!(id: params[:id], user: current_user)
@unit = current_user.units.find_by!(id: params[:id])
end
end

View File

@@ -2,12 +2,13 @@ class UsersController < ApplicationController
helper_method :allow_disguise?
before_action :find_user, only: [:show, :update, :disguise]
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
before_action except: :revert do
raise AccessForbidden unless current_user.at_least(:admin)
end
def index
@users = User.all
@@ -25,6 +26,7 @@ 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

View File

@@ -22,7 +22,7 @@ module ApplicationHelper
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))
@template.content_tag(:div, @object&.errors.full_messages.join(@template.tag(:br)))
end
private
@@ -38,70 +38,146 @@ module ApplicationHelper
end
def label_for(method, options = {})
return "" if (options[:label] == false)
return '' if options[:label] == false
text = options.delete(:label)
text ||= @object.class.human_attribute_name(method).capitalize
classes = @template.class_names(required: options[:required],
error: @object&.errors[method].present?)
label = label(method, "#{text}:", class: classes)
hint = options.delete(:hint)
label(method, text+":", class: classes) +
(@template.tag(:br) + @template.content_tag(:em, options.delete(:hint)) if options[:hint])
label + (@template.tag(:br) + @template.content_tag(:em, hint) if hint)
end
end
def labelled_form_for(record, options = {}, &block)
options.merge! builder: LabelledFormBuilder
options = options.deep_merge(builder: LabelledFormBuilder, data: {turbo: false})
form_for(record, **options) { |f| f.form_for(&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 = {})
value = object.public_send(method)
if value.is_a?(BigDecimal)
options[:value] = value.to_scientific
type = object.class.type_for_attribute(method)
options[:step] ||= BigDecimal(10).power(-type.scale)
options[:max] ||= BigDecimal(10).power(type.precision - type.scale) -
options[:step]
options[:min] = options[:min] == :step ? options[:step] : options[:min]
options[:min] ||= -options[:max]
end
super
end
def button(value = nil, options = {}, &block)
# button does not use #objectify_options
options.merge!(@options.slice(:form))
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
end
def tabular_fields_for(record_name, record_object = nil, options = {}, &block)
render_errors(record_name)
# 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)
options.merge!(builder: TabularFormBuilder, skip_default_ids: true)
render_errors(record_object || record_name)
fields_for(record_name, record_object, **options, &block)
end
def svg_tag(source, options = {})
content_tag :svg, options do
tag.use href: image_path(source + ".svg") + "#icon"
def tabular_form_with(**options, &block)
options = options.deep_merge(builder: TabularFormBuilder,
html: {autocomplete: 'off'})
form_with(**options, &block)
end
def svg_tag(source, label = nil, options = {})
svg_tag = content_tag :svg, options do
tag.use(href: "#{image_path(source + ".svg")}#icon")
end
label.blank? ? svg_tag : svg_tag + tag.span(label)
end
def navigation_menu
menu_tabs = [
['units', "weight-kilogram", :restricted, 'right'],
['users', "account-multiple-outline", :admin],
['measurements', 'scale-bathroom', :restricted],
['quantities', 'axis-arrow', :restricted, 'right'],
['units', 'weight-gram', :restricted],
['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}"),
link_to svg_tag("pictograms/#{image}", t("#{name}.navigation")),
{controller: "/#{name}", action: "index"},
class: class_names('tab', css_class, active: name == current_tab)
end
end.join.html_safe
end
[: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
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
end
def render_errors(record)
flash.now[:alert] = record.errors.full_messages unless record.errors.empty?
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)
html_options = html_options.deep_merge DISABLED_ATTRIBUTES if current_page?(options)
link_to name, options, html_options
end
def render_errors(records)
flash[:alert] ||= []
Array(records).each { |record| flash[:alert] += record.errors.full_messages }
end
def render_flash_messages
@@ -116,34 +192,34 @@ module ApplicationHelper
end
def render_no_items
tag.tr tag.td t('.no_items'), colspan: 10, class: 'hint'
tag.tr id: :no_items do
tag.td t("#{controller_path.tr('/', '.')}.no_items"), colspan: 10, class: 'hint'
end
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
# Converts value to HTML formatted scientific notation
def scientifize(d)
sign, coefficient, base, exponent = d.split
return 'NaN' unless sign
def link_or_button_options(type, name, image = nil, html_options)
name = svg_tag("pictograms/#{image}", name) if image
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
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
if exponent != 1
result += "10<sup>% d</sup>" % [exponent-1]
if type == :link && !(html_options[:onclick] || html_options.dig(:data, :turbo_stream))
name += '...'
end
result.html_safe
[name, html_options]
end
end

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
module QuantitiesHelper
def quantity_options(quantities, selected: nil)
values = quantities.map { |q| [sanitize('&emsp;' * q.depth + q.name), q.id] }
values.unshift([t('.select_quantity'), nil, {hidden: true}])
options_for_select(values, selected: selected)
end
end

View File

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

View File

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

View File

@@ -2,12 +2,15 @@
// 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)
/* Turbo stream actions */
Turbo.StreamElement.prototype.enableElement = function(element) {
element.removeAttribute("disabled")
element.removeAttribute("aria-disabled")
@@ -15,21 +18,6 @@ Turbo.StreamElement.prototype.enableElement = function(element) {
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")
@@ -42,46 +30,112 @@ Turbo.StreamActions.enable = function() {
this.targetElements.forEach((e) => { this.enableElement(e) })
}
Turbo.StreamActions.blur = function() {
blur()
}
Turbo.StreamActions.focus = function() {
// NOTE: call blur() before setting focus?
this.targetElements[0].focus({focusVisible: true})
}
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.hide = function() {
this.targetElements.forEach((e) => { e.style.display = "none" })
}
Turbo.StreamActions.close_form = function() {
this.targetElements.forEach((e) => {
this.removePreviousForm(e.closest(".form"))
/* Move focus if there's no focus or focus inside form being closed */
const focused = document.activeElement
if (!focused || (focused == document.body) || e.contains(focused)) {
let nextForm = e.parentElement.querySelector(`#${e.id} ~ tr:has([autofocus])`)
nextForm ??= e.parentElement.querySelector("tr:has([autofocus])")
nextForm?.querySelector("[autofocus]").focus()
}
document.getElementById(e.getAttribute("data-form")).remove()
if (e.hasAttribute("data-link")) {
this.enableElement(document.getElementById(e.getAttribute("data-link")))
}
if (e.hasAttribute("data-hidden-row")) {
document.getElementById(e.getAttribute("data-hidden-row")).removeAttribute("style")
}
e.remove()
})
}
/* Items table drag and drop support */
function processKey(event) {
if (event.key == "Escape") {
event.currentTarget.querySelector("a[name=cancel]").click();
}
}
window.processKey = processKey;
var lastEnterTime;
function dragStart(event) {
lastEnterTime = event.timeStamp;
var row = event.currentTarget;
row.closest("table").querySelectorAll("thead tr").forEach((tr) => {
tr.toggleAttribute("hidden");
});
event.dataTransfer.setData("text/plain", row.getAttribute("data-drag-path"));
var rowRectangle = row.getBoundingClientRect();
event.dataTransfer.setDragImage(row, event.x - rowRectangle.left, event.y - rowRectangle.top);
event.dataTransfer.dropEffect = "move";
}
window.dragStart = dragStart;
/*
* Drag tracking assumptions (based on FF 122.0 experience):
* * 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,3 +1,17 @@
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

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

170
app/models/quantity.rb Normal file
View File

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

7
app/models/readout.rb Normal file
View File

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

View File

@@ -1,34 +1,123 @@
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", dependent: :restrict_with_error, inverse_of: :base
has_many :subunits, class_name: "Unit", inverse_of: :base, dependent: :restrict_with_error
validate if: ->{ base.present? } do
errors.add(:base, :user_mismatch) unless user == base.user
errors.add(:base, :multilevel_nesting) if base.base.present?
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?
end
validates :symbol, presence: true, uniqueness: {scope: :user_id},
length: {maximum: columns_hash['symbol'].limit}
validates :name, length: {maximum: columns_hash['name'].limit}
length: {maximum: type_for_attribute(:symbol).limit}
validates :description, length: {maximum: type_for_attribute(:description).limit}
validates :multiplier, numericality: {equal_to: 1}, unless: :base
validates :multiplier, numericality: {other_than: 0}, if: :base
validates :multiplier, numericality: {greater_than: 0, precision: true, scale: true}, if: :base
scope :defaults, ->{ where(user: nil) }
scope :ordered, ->{
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)
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)
}
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,11 +7,24 @@ 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
}, default: :active, validate: true
has_many :units, -> { ordered }, dependent: :destroy
has_many :readouts, dependent: :destroy
accepts_nested_attributes_for :readouts
has_many :quantities, dependent: :destroy
has_many :units, dependent: :destroy
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
def at_least(status)
User.statuses[self.status] >= User.statuses[status]

View File

@@ -0,0 +1,27 @@
<%= 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="actions">
<% 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

@@ -0,0 +1,22 @@
<div class="rightside 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' %>
</div>
<table class="main items">
<thead>
<tr>
<th><%= User.human_attribute_name(:symbol).capitalize %></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,42 +5,41 @@
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;">
<html<%= ' style="visibility: hidden;"' if Rails.env.test? -%>>
<head>
<title>fixin.me</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application" %>
<%= stylesheet_link_tag "spreadsheet" %>
<%= javascript_importmap_tags %>
<%# 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") %>
<%#= 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) %>
</head>
<body>
<header class="header">
<header class="hflex">
<%= image_link_to t(".source_code"), "code-braces", source_code_url %>
<%= image_link_to t(".issue_tracker"), "bug-outline", issue_tracker_url,
class: "extendedright" %>
<% if user_signed_in? %>
<%= image_link_to_unless_current(current_user.email, "account-wrench-outline",
edit_user_registration_path) {} %>
<%= image_link_to_unless_current(current_user, "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 %>
method: :delete, data: {turbo: false} %>
<% 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>

View File

@@ -0,0 +1,29 @@
<%= tabular_form_with model: Measurement.new do |form| %>
<fieldset>
<legend>
<%= tag.span id: :measurement_form_legend %>
<%= image_link_to '', "close-outline", measurements_path, name: :cancel,
class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</legend>
<table class="items">
<tbody id="readouts">
<tr id="readouts_form">
<td>
<%= select_tag :id, quantity_options(@quantities), onchange:
"this.form.requestSubmit(document.getElementById('readout_submit'));",
class: 'quantity' %>
<%= form.submit id: :readout_submit, name: nil, value: nil,
formaction: new_readout_path, formmethod: :get, formnovalidate: true,
hidden: true, data: {turbo_stream: true} %>
</td>
<td></td>
<td></td>
<td></td>
</tr>
<tr id="measurement_form_actions">
<td colspan="4"><div class="actions centered"><%= form.button %></div></td>
</tr>
</tbody>
</table>
</fieldset>
<% end %>

View File

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

View File

@@ -0,0 +1,10 @@
<%# TODO: show hint when no quantities/units defined %>
<div class="rightside 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>
<%= tag.div class: 'main', id: :measurement_form %>

View File

@@ -0,0 +1,4 @@
<%= turbo_stream.disable :new_measurement_link -%>
<%= turbo_stream.update :measurement_form do %>
<%= render partial: 'form' %>
<% end %>

View File

@@ -0,0 +1,19 @@
<%= tabular_fields_for @quantity, form: form_tag do |form| %>
<%- tag.tr id: row, class: "form", onkeydown: "processKey(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="actions">
<%= 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

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

View File

@@ -0,0 +1,24 @@
<%= tag.tr id: dom_id(quantity),
ondragstart: "dragStart(event)", ondragend: "dragEnd(event)",
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drag_path: reparent_quantity_path(quantity), drop_id: dom_id(quantity),
drop_id_param: "quantity[parent_id]"} do %>
<td class="link" style="--depth:<%= quantity.depth %>">
<%= link_to quantity, edit_quantity_path(quantity), onclick: 'this.blur();',
data: {turbo_stream: true} %>
</td>
<td><%= quantity.description %></td>
<% if current_user.at_least(:active) %>
<td class="actions">
<%= image_link_to t('.new_subquantity'), 'plus-outline', new_quantity_path(quantity),
id: dom_id(quantity, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>
<%= 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

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

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

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

@@ -0,0 +1,33 @@
<div class="rightside 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' %>
</div>
<%= tag.div class: 'main', id: :quantity_form %>
<table class="main items">
<thead>
<tr>
<th><%= Quantity.human_attribute_name(:name).capitalize %></th>
<th><%= Quantity.human_attribute_name(:description).capitalize %></th>
<% if current_user.at_least(:active) %>
<th><%= t :actions %></th>
<th></th>
<% end %>
</tr>
<%= tag.tr id: "quantity_", hidden: true,
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drop_id: "quantity_", drop_id_param: "quantity[parent_id]"} do %>
<th colspan="4"><%= t '.top_level_drop' %></th>
<% end %>
</thead>
<tbody id="quantities">
<%= render(@quantities) || render_no_items %>
</tbody>
</table>

View File

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

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

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

View File

@@ -0,0 +1,30 @@
<%= tabular_fields_for 'readouts[]', readout do |form| %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout),
onkeydown: 'processKey(event)' do %>
<td>
<%#= readout.quantity.relative_pathname(@common_ancestor) %>
<%= form.collection_select :quantity_id, @user_quantities,
:id, ->(q){ sanitize('&emsp;' * q.depth + q.name) },
{disabled: @quantities.map(&:id).delete!(form.object.quantity.id)},
{class: 'quantity'} %>
</td>
<td>
<%= form.number_field :value, required: true, autofocus: true, size: 10 %>
</td>
<td>
<%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) } %>
</td>
<td class="actions">
<%= image_button_tag t('.new_children'), 'plus-multiple-outline',
formaction: new_measurement_path(readout.quantity, :children),
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>
<%#= image_button_tag t('.new_subtree'), 'plus-multiple-outline',
formaction: new_measurement_path(:subtree), **common_options -%>
<%= image_button_tag '', 'delete-outline', class: 'dangerous',
formaction: discard_readouts_path(readout.quantity),
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>
</td>
<% end %>
<% end %>

View File

@@ -0,0 +1,2 @@
<%= turbo_stream.remove dom_id(@quantity, :new, :readout) %>
<%= render partial: 'form_repath' %>

View File

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

View File

@@ -0,0 +1,8 @@
<%#= render partial: 'form_repath' %>
<% @readouts.each do |r| %>
<%= turbo_stream.disable_all "select.quantity option[value='#{r.quantity_id}']" %>
<% end %>
<%= turbo_stream.before :readouts_form do %>
<%= render partial: 'form', collection: @readouts, as: :readout %>
<% end %>

View File

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

View File

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

View File

@@ -1,25 +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)} 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),
drop_id_param: "unit[base_id]"} do %>
<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 class="link" style="--depth:<%= unit.base_id? ? 1 : 0 %>">
<%= link_to unit, edit_unit_path(unit), onclick: 'this.blur();', data: {turbo_stream: true} %>
</td>
<td><%= unit.name %></td>
<td class="number"><%= scientifize(unit.multiplier) %></td>
<td><%= unit.description %></td>
<td class="number"><%= unit.multiplier.to_html %></td>
<% if current_user.at_least(:active) %>
<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} %>
<% 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} %>
<% end %>
<%= image_button_to t(".delete_unit"), "delete-outline", unit_path(unit),
<%= image_button_to_if unit.movable?, t('.destroy'), 'delete-outline', unit_path(unit),
method: :delete %>
</td>
<% if unit.movable? %>

View File

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

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

View File

@@ -0,0 +1,3 @@
<%= 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,6 +1,13 @@
<%= turbo_stream.replace :unit_form do %>
<%= form_with model: @unit, html: {id: :unit_form} do %>
<% 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 %>
<% end %>
<% end %>
<%= turbo_stream.replace_form @unit, partial: 'form', locals: {link_id: dom_id(@unit, :edit)} %>
<%= turbo_stream.hide ids[:hidden_row] %>
<%= turbo_stream.remove ids[:row] %>
<%= turbo_stream.after @unit, partial: 'form', locals: ids -%>

View File

@@ -1,10 +1,9 @@
<div class="rightside buttongrid">
<% if current_user.at_least(:active) %>
<%= 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} %>
<%= image_link_to t('.new_unit'), 'plus-outline', new_unit_path,
id: dom_id(Unit, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>
<% end %>
<%= image_link_to t('.import_units'), 'download-outline', default_units_path, class: 'tools' %>
</div>
<%= tag.div id: :unit_form %>
@@ -13,17 +12,17 @@
<thead>
<tr>
<th><%= User.human_attribute_name(:symbol).capitalize %></th>
<th><%= User.human_attribute_name(:name).capitalize %></th>
<th><%= User.human_attribute_name(:description).capitalize %></th>
<th><%= User.human_attribute_name(:multiplier).capitalize %></th>
<% if current_user.at_least(:active) %>
<th><%= t :actions %></th>
<th></th>
<% end %>
</tr>
<%= tag.tr id: 'unit_', hidden: true,
ondragover: 'dragOver(event)', ondrop: 'drop(event)',
ondragenter: 'dragEnter(event)', ondragleave: 'dragLeave(event)',
data: {drop_id: 'unit_'} do %>
<%= tag.tr id: "unit_", hidden: true,
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drop_id: "unit_", drop_id_param: "unit[base_id]"} do %>
<th colspan="5"><%= t '.top_level_drop' %></th>
<% end %>
</thead>
@@ -31,81 +30,3 @@
<%= 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,9 +1,20 @@
<% link_id = dom_id(@unit.base || @unit, :add) %>
<%= turbo_stream.disable link_id -%>
<% 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)} %>
<%= turbo_stream.replace :unit_form do %>
<%= form_with model: @unit, html: {id: :unit_form} do %>
<%= 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? %>
<% end %>
<% end %>
<%= turbo_stream.insert_form (@unit.base || :units), partial: 'form', locals: {link_id: link_id} %>
<% 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 %>

View File

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

View File

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

View File

@@ -4,14 +4,16 @@
<th><%= User.human_attribute_name(:email).capitalize %></th>
<th><%= User.human_attribute_name(:status).capitalize %></th>
<th><%= User.human_attribute_name(:confirmed_at).capitalize %></th>
<th><%= User.human_attribute_name(:created_at).capitalize %>&nbsp;<sup>UTC</sup></th>
<th>
<%= User.human_attribute_name(:created_at).capitalize %>&nbsp;<sup>UTC</sup>
</th>
<th><%= t :actions %></th>
</tr>
</thead>
<tbody>
<% @users.each do |user| %>
<tr>
<td class="link"><%= link_to user.email, user_path(user) %></td>
<td class="link"><%= link_to user, user_path(user) %></td>
<td>
<% if user == current_user %>
<%= user.status %>
@@ -22,15 +24,15 @@
<% end %>
<% end %>
</td>
<td class="svg">
<%= svg_tag "pictograms/checkbox-marked-outline" if user.confirmed_at.present? %>
</td>
<td><%= user.created_at.to_fs(:db_without_sec) %></td>
<td class="actions">
<% 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,3 +1,3 @@
<p>Hello <%= @resource.email %>!</p>
<p>Hello <%= @resource %>!</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.email %>!</p>
<p>Hello <%= @resource %>!</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.email %>!</p>
<p>Hello <%= @resource %>!</p>
<p>Your account has been locked due to an excessive number of unsuccessful sign in attempts.</p>

View File

@@ -1,11 +1,12 @@
<% content_for :navigation, flush: true do %>
<%= image_link_to t(:back), 'arrow-left-bold-outline',
request.referer.present? ? :back : root_path %>
<%= link_to svg_tag("pictograms/arrow-left-bold-outline", t(:back)),
request.referer.present? ? :back : root_path, class: 'tab' %>
<% 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")} %>
form_class: 'tools', method: :delete, data: {turbo: false},
onclick: {confirm: t(".confirm_delete")} %>
</div>
<%= labelled_form_for resource, url: registration_path(resource),

16
bin/fixinme.service.dist Normal file
View File

@@ -0,0 +1,16 @@
[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,6 +23,10 @@ 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
@@ -32,7 +36,7 @@ module FixinMe
# config.eager_load_paths << Rails.root.join("extras")
config.action_dispatch.rescue_responses['ApplicationController::AccessForbidden'] = :forbidden
config.action_dispatch.rescue_responses['ArgumentError'] = :bad_request
config.action_dispatch.rescue_responses['ApplicationController::ParameterInvalid'] = :unprocessable_entity
# SETUP: Below settings need to be updated on a per-installation basis.
#
@@ -43,9 +47,12 @@ module FixinMe
# List of hosts this app is available at.
# https://guides.rubyonrails.org/configuring.html#actiondispatch-hostauthorization
config.hosts << 'localhost'
config.hosts += ['localhost', 'example.com', IPAddr.new('1.2.3.4/32')]
# Email address of admin account
config.admin = 'admin@localhost'
# 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-password"
# DATABASE_PASSWORD="Some-password1%"
#
# or
#
@@ -26,24 +26,25 @@
default: &default
adapter: mysql2
encoding: utf8mb4
collation: utf8mb4_0900_as_ci
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
username: fixinme
password:
password: Some-password1%
socket: /run/mysqld/mysqld.sock
production:
<<: *default
database: fixinme_production
database: fixinme
# Unless you're planning on developing the application, you can skip/remove
# Unless you're planning on developing the application, you can skip
# configurations for development and test databases altogether.
development:
<<: *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

@@ -0,0 +1,26 @@
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,7 +24,8 @@ 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.
config.mailer_sender = 'fixinme@noreply.me'
# This is set in 'config/application.rb'.
#config.mailer_sender = 'fixinme@noreply.me'
# Configure the class responsible to send e-mails.
# config.mailer = 'Devise::Mailer'

View File

@@ -1,3 +0,0 @@
ActiveSupport.on_load :action_dispatch_system_test_case do
prepend CoreExt::ActionDispatch::SystemTesting::TestHelpers::CustomScreenshotHelperUniqueId
end

View File

@@ -15,24 +15,8 @@ ActiveSupport.on_load :turbo_streams_tag_builder do
action_all :enable, targets, allow_inferred_rendering: false
end
def blur_all
action :blur, nil, allow_inferred_rendering: false
end
def focus(target)
action :focus, target, allow_inferred_rendering: false
end
def insert_form(target, content = nil, **rendering, &block)
if target.is_a? Symbol
action :prepend_form, target, content, **rendering, &block
else
action :after_form, target, content, **rendering, &block
end
end
def replace_form(target, content = nil, **rendering, &block)
action :replace_form, target, content, **rendering, &block
def hide(target)
action :hide, target, allow_inferred_rendering: false
end
def close_form(target)

View File

@@ -1,4 +1,8 @@
en:
errors:
messages:
precision_exceeded: must not exceed %{value} significant digits
scale_exceeded: must not exceed %{value} decimal digits
activerecord:
attributes:
unit:
@@ -19,11 +23,20 @@ en:
attributes:
base:
multilevel_nesting: has to be a top-level unit
self_reference: of an unit cannot be the unit itself
user_mismatch: has to belong to the same user as unit
multiplier:
equal_to: for a top-level unit has to be 1
symbol:
taken: has to be unique
quantity:
attributes:
name:
taken: has to be unique among siblings
parent:
descendant_reference: cannot be changed to any of descendants
self_reference: of the quantitiy cannot be the quantity itself
user_mismatch: has to belong to the same user as quantity
actioncontroller:
exceptions:
status:
@@ -33,6 +46,9 @@ en:
forbidden: >
You have not been granted access to this action (403 Forbidden).
This should not happen, please notify site administrator.
not_found: >
The record that you requested operation on does not exist (404 Not Found).
This should not happen, please notify site administrator.
unprocessable_entity: >
The request is semantically incorrect and was rejected (422 Unprocessable Entity).
This should not happen, please notify site administrator.
@@ -46,30 +62,71 @@ en:
revert: Revert
sign_out: Sign out
source_code: Get code
units: Units
users: Users
units:
unit:
add_subunit: Subunit
delete_unit: Delete
measurements:
navigation: Measurements
form:
select_quantity: select quantity...
index:
add_unit: Add unit
import_units: Import...
no_items: There are no configured units. You can try to import some defaults.
top_level_drop: Drop here to reposition into top-level unit
new:
none: none
new_measurement: Add measurement
readouts:
form:
new_children: Children
new_subtree: Subtree
quantities:
navigation: Quantities
no_items: There are no configured quantities. You can Add some or Import from defaults.
quantity:
new_subquantity: Child
destroy: Delete
index:
new_quantity: Add quantity
top_level_drop: Drop here to reposition into top-level quantity
create:
success: Created new unit
success: Created new quantity "%{quantity}"
update:
success: Updated unit
rebase:
multiplier_reset: Multiplier of "%{symbol}" has been reset to 1, due to repositioning
success: Updated quantity "%{quantity}"
destroy:
success: Deleted unit
users:
success: Deleted quantity "%{quantity}"
units:
navigation: Units
no_items: There are no configured units. You can Add some or Import from defaults.
unit:
new_subunit: Subunit
destroy: Delete
index:
disguise: View as...
new_unit: Add unit
import_units: Import
top_level_drop: Drop here to reposition into top-level unit
create:
success: Created new unit "%{unit}"
update:
success: Updated unit "%{unit}"
rebase:
multiplier_reset: Multiplier of "%{unit}" has been reset to 1, due to repositioning
destroy:
success: Deleted unit "%{unit}"
default:
units:
no_items: There are no importable defaults or exportable units.
unit:
delete: Delete
export: Export
import: Import
index:
actions: Actions on defaults
back: Back to units
import_all: Import all
no_items: There are no differences between default and user units.
import:
success: Imported unit "%{unit}"
export:
success: Exported unit "%{unit}"
destroy:
success: Deleted unit "%{unit}"
users:
navigation: Users
index:
disguise: View as
passwords:
edit:
new_password: New password
@@ -98,6 +155,7 @@ en:
add: Add
back: Back
cancel: Cancel
delete: Delete
or: or
register: Register
sign_in: Sign in

View File

@@ -1,30 +1,47 @@
Rails.application.routes.draw do
devise_for :users, path: '', path_names: {registration: 'profile'},
controllers: {registrations: :registrations}
resources :measurements
resources :readouts, only: [:new], path_names: {new: '/new(/:id/:scope)'},
constraints: {scope: /children|subtree|leaves/} do
collection {get 'new/:id/discard', action: :discard, as: :discard}
end
resources :quantities, except: [:show], path_names: {new: '(/:id)/new'} do
member { post :reparent }
end
resources :units, except: [:show], path_names: {new: '(/:id)/new'} do
member do
post :rebase
member { post :rebase }
end
namespace :default do
resources :units, only: [:index, :destroy] do
member { post :import, :export }
#collection { post :import_all }
end
end
namespace :units do
get 'defaults/index'
# Devise does not handle properly models that require database access during loading.
# https://github.com/heartcombo/devise/issues/5786
connection = ActiveRecord::Base.connection
if connection.schema_version && connection.table_exists?(:users)
devise_for :users, path: '', path_names: {registration: 'profile'},
controllers: {registrations: :registrations}
end
resources :users, only: [:index, :show, :update] do
member do
get :disguise
end
collection do
get :revert
end
member { get :disguise }
collection { get :revert }
end
devise_scope :user do
root to: "devise/sessions#new"
unauthenticated do
as :user do
root to: redirect('/sign_in')
end
end
root to: redirect('/units'), as: :user_root
direct(:source_code) { "https://gitea.michalczyk.pro/fixin.me/fixin.me" }
direct(:issue_tracker) { "https://gitea.michalczyk.pro/fixin.me/fixin.me/issues" }
direct(:source_code) { 'https://gitea.michalczyk.pro/fixin.me/fixin.me' }
direct(:issue_tracker) { 'https://gitea.michalczyk.pro/fixin.me/fixin.me/issues' }
end

View File

@@ -2,12 +2,12 @@ class CreateUnits < ActiveRecord::Migration[7.0]
def change
create_table :units do |t|
t.references :user, foreign_key: true
t.string :symbol
t.string :name
t.decimal :multiplier, precision: 30, scale: 15, default: 1.0
t.references :base
t.string :symbol, null: false, limit: 15
t.text :description
t.decimal :multiplier, null: false, precision: 30, scale: 15, default: 1.0
t.references :base, foreign_key: {to_table: :units}
t.timestamps
t.timestamps null: false
end
add_index :units, [:user_id, :symbol], unique: true
end

View File

@@ -0,0 +1,17 @@
class CreateQuantities < ActiveRecord::Migration[7.2]
def change
create_table :quantities do |t|
t.references :user, foreign_key: true
t.string :name, null: false, limit: 31
t.text :description
t.references :parent, foreign_key: {to_table: :quantities}
t.timestamps null: false
# Caches; can be computed from other attributes
t.integer :depth, null: false, default: 0
t.string :pathname, null: false, limit: 511
end
add_index :quantities, [:user_id, :parent_id, :name], unique: true
end
end

View File

@@ -0,0 +1,15 @@
class CreateReadouts < ActiveRecord::Migration[7.2]
def change
create_table :readouts do |t|
t.references :user, null: false, foreign_key: true
t.references :quantity, null: false, foreign_key: true
t.references :unit, foreign_key: true
t.decimal :value, null: false, precision: 30, scale: 15
#t.references :collector, foreign_key: true
#t.references :device, foreign_key: true
t.timestamps null: false
end
add_index :readouts, [:quantity_id, :created_at], unique: true
end
end

View File

@@ -10,12 +10,39 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2023_06_02_185352) do
create_table "units", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
ActiveRecord::Schema[7.2].define(version: 2025_01_21_230456) do
create_table "quantities", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|
t.bigint "user_id"
t.string "symbol"
t.string "name"
t.decimal "multiplier", precision: 30, scale: 15, default: "1.0"
t.string "name", limit: 31, null: false
t.text "description"
t.bigint "parent_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "depth", default: 0, null: false
t.string "pathname", limit: 511, null: false
t.index ["parent_id"], name: "index_quantities_on_parent_id"
t.index ["user_id", "parent_id", "name"], name: "index_quantities_on_user_id_and_parent_id_and_name", unique: true
t.index ["user_id"], name: "index_quantities_on_user_id"
end
create_table "readouts", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|
t.bigint "user_id", null: false
t.bigint "quantity_id", null: false
t.bigint "unit_id"
t.decimal "value", precision: 30, scale: 15, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["quantity_id", "created_at"], name: "index_readouts_on_quantity_id_and_created_at", unique: true
t.index ["quantity_id"], name: "index_readouts_on_quantity_id"
t.index ["unit_id"], name: "index_readouts_on_unit_id"
t.index ["user_id"], name: "index_readouts_on_user_id"
end
create_table "units", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|
t.bigint "user_id"
t.string "symbol", limit: 15, null: false
t.text "description"
t.decimal "multiplier", precision: 30, scale: 15, default: "1.0", null: false
t.bigint "base_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@@ -24,7 +51,7 @@ ActiveRecord::Schema[7.1].define(version: 2023_06_02_185352) do
t.index ["user_id"], name: "index_units_on_user_id"
end
create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_as_ci", force: :cascade do |t|
t.string "email", limit: 64, null: false
t.integer "status", default: 0, null: false
t.datetime "created_at", null: false
@@ -42,5 +69,11 @@ ActiveRecord::Schema[7.1].define(version: 2023_06_02_185352) do
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end
add_foreign_key "quantities", "quantities", column: "parent_id"
add_foreign_key "quantities", "users"
add_foreign_key "readouts", "quantities"
add_foreign_key "readouts", "units"
add_foreign_key "readouts", "users"
add_foreign_key "units", "units", column: "base_id"
add_foreign_key "units", "users"
end

View File

@@ -20,19 +20,4 @@ end
# Formulas will be deleted as dependent on Quantities
#[Source, Quantity, Unit].each { |model| model.defaults.delete_all }
Unit.transaction do
Unit.defaults.delete_all
unit_1 = Unit.create symbol: "1", name: "dimensionless, one"
Unit.create symbol: "%", base: unit_1, multiplier: 1e-2, name: "percent"
Unit.create symbol: "", base: unit_1, multiplier: 1e-3, name: "promille"
Unit.create symbol: "", base: unit_1, multiplier: 1e-4, name: "basis point"
Unit.create symbol: "ppm", base: unit_1, multiplier: 1e-6, name: "parts per million"
unit_g = Unit.create symbol: "g", name: "gram"
Unit.create symbol: "ug", base: unit_g, multiplier: 1e-6, name: "microgram"
Unit.create symbol: "mg", base: unit_g, multiplier: 1e-3, name: "milligram"
Unit.create symbol: "kg", base: unit_g, multiplier: 1e3, name: "kilogram"
Unit.create symbol: "kcal", name: "kilocalorie"
end
require_relative 'seeds/units.rb'

View File

@@ -0,0 +1,10 @@
Unit.transaction do
Unit.defaults.order(Unit.arel_table[:base_id].eq(nil)).delete_all
units = {}
<% Unit.defaults.ordered.each do |unit| %>
<%= "\n" if unit.base.nil? %>
units['<%= unit.symbol %>'] =
Unit.create symbol: '<%= unit.symbol %>',<% unless unit.base.nil? %> base: units['<%= unit.base.symbol %>'], multiplier: '<%= unit.multiplier.to_scientific %>',<% end %>
description: '<%= unit.description %>'
<% end %>
end

38
db/seeds/units.rb Normal file
View File

@@ -0,0 +1,38 @@
Unit.transaction do
Unit.defaults.order(Unit.arel_table[:base_id].eq(nil)).delete_all
units = {}
units['1'] =
Unit.create symbol: '1',
description: 'dimensionless, one'
units['ppm'] =
Unit.create symbol: 'ppm', base: units['1'], multiplier: '1e-6',
description: 'parts per million'
units['‱'] =
Unit.create symbol: '‱', base: units['1'], multiplier: '1e-4',
description: 'basis point'
units['‰'] =
Unit.create symbol: '‰', base: units['1'], multiplier: '1e-3',
description: 'promille'
units['%'] =
Unit.create symbol: '%', base: units['1'], multiplier: '1e-2',
description: 'percent'
units['g'] =
Unit.create symbol: 'g',
description: 'gram'
units['µg'] =
Unit.create symbol: 'µg', base: units['g'], multiplier: '1e-6',
description: 'microgram'
units['mg'] =
Unit.create symbol: 'mg', base: units['g'], multiplier: '1e-3',
description: 'milligram'
units['kg'] =
Unit.create symbol: 'kg', base: units['g'], multiplier: '1e3',
description: 'kilogram'
units['kcal'] =
Unit.create symbol: 'kcal',
description: 'kilocalorie'
end

View File

@@ -1,4 +1,4 @@
module CoreExt::ActionDispatch::SystemTesting::TestHelpers::CustomScreenshotHelperUniqueId
module CoreExt::ActionDispatch::SystemTesting::TestHelpers::ScreenshotHelperUniqueId
private
def unique

View File

@@ -0,0 +1,9 @@
module CoreExt::ActionView::RecordIdentifierWithSuffix
def dom_id(object, prefix = nil, suffix = nil)
if suffix
"#{super(object, prefix)}#{::ActionView::RecordIdentifier::JOIN}#{suffix}"
else
super(object, prefix)
end
end
end

View File

@@ -0,0 +1,19 @@
module CoreExt::ActiveModel::Validations::NumericalityValidatesPrecisionAndScale
def validate_each(record, attr_name, value, ...)
super(record, attr_name, value, ...)
if options[:precision] || options[:scale]
attr_type = record.class.type_for_attribute(attr_name)
# For conversion of 'value' to BigDecimal 'ndigits' is not supplied intentionally,
# to avoid silent rounding. It is only required for conversion from Float and
# Rational, which should not happen.
value = BigDecimal(value) unless value.is_a? BigDecimal
if options[:precision] && (value.precision > attr_type.precision)
record.errors.add(attr_name, :precision_exceeded, **filtered_options(attr_type.precision))
end
if options[:scale] && (value.scale > attr_type.scale)
record.errors.add(attr_name, :scale_exceeded, **filtered_options(attr_type.scale))
end
end
end
end

View File

@@ -0,0 +1,3 @@
module CoreExt::ActiveRecord::Relation::UpdateAndDeleteCteSupport
INVALID_METHODS_FOR_UPDATE_AND_DELETE_ALL = [:distinct]
end

View File

@@ -0,0 +1,13 @@
module CoreExt::Arel::CrudCteUpdateAndDelete
def compile_update(...)
um = super
um.with = subqueries
um
end
def compile_delete(...)
dm = super
dm.with = subqueries
dm
end
end

View File

@@ -0,0 +1,16 @@
module CoreExt::Arel::Nodes::DeleteStatementCteUpdateAndDelete
attr_accessor :with
def initialize(...)
super
@with = nil
end
def hash
[self.class, @relation, @wheres, @orders, @limit, @offset, @key, @with].hash
end
def eql?(other)
eql?(other) && self.with == other.with
end
end

View File

@@ -0,0 +1,16 @@
module CoreExt::Arel::Nodes::UpdateStatementCteUpdateAndDelete
attr_accessor :with
def initialize(...)
super
@with = nil
end
def hash
[self.class, @relation, @wheres, @orders, @limit, @offset, @key, @with].hash
end
def eql?(other)
eql?(other) && self.with == other.with
end
end

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