158 Commits

Author SHA1 Message Date
d893e59293 Clean up and improve items-table styling
Closes #9
2026-03-25 18:42:24 +01:00
33004f62bd Improve Measurement form layout and styling 2026-03-22 01:06:38 +01:00
687e6fcdff Drop Readout.value decimal type in favor of float 2026-03-19 20:30:16 +01:00
5ed066ad18 Unify border/outline parameters order 2026-03-06 01:42:32 +01:00
dde4e52f1b Fix form elements styling on hover 2026-03-06 01:33:23 +01:00
a9091d76a8 Merge styles of <a>.button/<button>/<input type="submit"> into .button
Remove flash button
Fix some multi-selector specificity differences
2026-03-04 17:13:11 +01:00
4175d31b9d Update and format comments 2026-03-03 01:31:44 +01:00
c659201904 Make [disabled] and [hidden] styles !important 2026-03-03 01:14:12 +01:00
83b064ef3c Merge recover password/resend confirmation forms into sign in/register
Closes #65, #66
2026-03-01 20:04:42 +01:00
ea8bff9b3d Bundle update to Rails 7.2.3, Devise 5 2026-02-26 02:39:11 +01:00
80130fb7d1 Allow cascade delete Unit/Quantity
Closes #32
2026-02-22 17:50:43 +01:00
1ba7d29441 Update tests to match labeled form changes 2026-02-22 00:55:21 +01:00
84945fa4b4 Simplify and improve labeled form 2026-02-22 00:53:18 +01:00
675eb0aad8 Optimize styles; clean up <fieldset> 2026-02-03 15:33:37 +01:00
bd1a664caa Measurement form based on select-styled <details> 2026-01-31 17:22:09 +01:00
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
135 changed files with 3111 additions and 1182 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

34
DESIGN.md Normal file
View File

@@ -0,0 +1,34 @@
DESIGN
======
Below is a list of design decisions. The justification is to be consulted
whenever a change is considered, to avoid regressions.
### Data type for DB storage of numeric values (`decimal` vs `float`)
* among database engines supported (by Rails), SQLite offers storage of
`decimal` data type with the lowest precision, equal to the precision of
`REAL` type (double precision float value, IEEE 754), but in a floating point
format,
* decimal types in other database engines offer greater precision, but store
data in a fixed point format,
* biology-related values differ by several orders of magnitude; storing them in
fixed point format would only make sense if required precision would be
greater than that offered by floating point format,
* even then, fixed point would mean either bigger memory requirements or
worse precision for numbers close to scale limit,
* for a fixed point format to use the same 8 bytes of storage as IEEE
754, precision would need to be limited to 18 digits (4 bytes/9 digits)
and scale approximately half of that - 9,
* double precision floating point guarantees 15 digits of precision, which
is more than enough for all expected use cases,
* single precision floating point only guarntees 6 digits of precision,
which is estimated to be too low for some use cases (e.g. storing
latitude/longitude with a resolution grater than 100m)
* double precision floating point (IEEE 754) is a standard that ensures
compatibility with all database engines,
* the same data format is used internally by Ruby as a `Float`; it
guarantees no conversions between storage and computation,
* as a standard with hardware implementations ensures both: computing
efficiency and hardware/3rd party library compatibility as opposed to Ruby
custom `BigDecimal` type

31
Gemfile
View File

@@ -1,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.3"
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"
@@ -32,4 +42,11 @@ group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara"
gem "selenium-webdriver"
# Remove minitest version restriction after error fixed:
# railties-7.2.3/lib/rails/test_unit/line_filtering.rb:7:in `run':
# wrong number of arguments (given 3, expected 1..2) (ArgumentError)
# from /var/www/.gem/ruby/3.3.0/gems/minitest-6.0.2/lib/minitest.rb:473:in
# `block (2 levels) in run_suite'
gem "minitest", "< 6"
end

View File

@@ -1,205 +1,247 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (7.1.3)
actionpack (= 7.1.3)
activesupport (= 7.1.3)
actioncable (7.2.3)
actionpack (= 7.2.3)
activesupport (= 7.2.3)
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.3)
actionpack (= 7.2.3)
activejob (= 7.2.3)
activerecord (= 7.2.3)
activestorage (= 7.2.3)
activesupport (= 7.2.3)
mail (>= 2.8.0)
actionmailer (7.2.3)
actionpack (= 7.2.3)
actionview (= 7.2.3)
activejob (= 7.2.3)
activesupport (= 7.2.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.1.3)
actionview (= 7.1.3)
activesupport (= 7.1.3)
actionpack (7.2.3)
actionview (= 7.2.3)
activesupport (= 7.2.3)
cgi
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
rack (>= 2.2.4, < 3.3)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
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.3)
actionpack (= 7.2.3)
activerecord (= 7.2.3)
activestorage (= 7.2.3)
activesupport (= 7.2.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.1.3)
activesupport (= 7.1.3)
actionview (7.2.3)
activesupport (= 7.2.3)
builder (~> 3.1)
cgi
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.1.3)
activesupport (= 7.1.3)
activejob (7.2.3)
activesupport (= 7.2.3)
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.3)
activesupport (= 7.2.3)
activerecord (7.2.3)
activemodel (= 7.2.3)
activesupport (= 7.2.3)
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.3)
actionpack (= 7.2.3)
activejob (= 7.2.3)
activerecord (= 7.2.3)
activesupport (= 7.2.3)
marcel (~> 1.0)
activesupport (7.1.3)
activesupport (7.2.3)
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)
base64 (0.2.0)
bcrypt (3.1.20)
bigdecimal (3.1.6)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
base64 (0.3.0)
bcrypt (3.1.21)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
builder (3.2.4)
byebug (11.1.3)
capybara (3.39.2)
builder (3.3.0)
byebug (13.0.0)
reline (>= 0.6.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)
cgi (0.5.1)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
date (3.3.4)
devise (4.9.3)
date (3.5.1)
devise (5.0.2)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
railties (>= 7.0)
responders
warden (~> 1.2.3)
drb (2.2.0)
ruby2_keywords
erubi (1.12.0)
ffi (1.16.3)
globalid (1.2.1)
drb (2.2.3)
erb (6.0.2)
erubi (1.13.1)
ffi (1.17.3-aarch64-linux-gnu)
ffi (1.17.3-aarch64-linux-musl)
ffi (1.17.3-arm-linux-gnu)
ffi (1.17.3-arm-linux-musl)
ffi (1.17.3-arm64-darwin)
ffi (1.17.3-x86_64-darwin)
ffi (1.17.3-x86_64-linux-gnu)
ffi (1.17.3-x86_64-linux-musl)
globalid (1.3.0)
activesupport (>= 6.1)
i18n (1.14.1)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
importmap-rails (2.0.1)
importmap-rails (2.2.3)
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.2)
irb (1.17.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
loofah (2.22.0)
logger (1.7.0)
loofah (2.25.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
mail (2.9.0)
logger
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
marcel (1.0.2)
matrix (0.4.2)
marcel (1.1.0)
matrix (0.4.3)
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.27.0)
mysql2 (0.5.7)
bigdecimal
net-imap (0.6.3)
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.5)
nokogiri (1.19.1-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.1-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.1-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.1-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-musl)
racc (~> 1.4)
orm_adapter (0.5.0)
psych (5.1.2)
pg (1.6.3)
pg (1.6.3-aarch64-linux)
pg (1.6.3-aarch64-linux-musl)
pg (1.6.3-arm64-darwin)
pg (1.6.3-x86_64-darwin)
pg (1.6.3-x86_64-linux)
pg (1.6.3-x86_64-linux-musl)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
psych (5.3.1)
date
stringio
public_suffix (5.0.4)
puma (6.4.2)
public_suffix (7.0.2)
puma (6.6.1)
nio4r (~> 2.0)
racc (1.7.3)
rack (3.0.8)
rack-session (2.0.0)
racc (1.8.1)
rack (3.2.5)
rack-session (2.1.1)
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.3.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.3)
actioncable (= 7.2.3)
actionmailbox (= 7.2.3)
actionmailer (= 7.2.3)
actionpack (= 7.2.3)
actiontext (= 7.2.3)
actionview (= 7.2.3)
activejob (= 7.2.3)
activemodel (= 7.2.3)
activerecord (= 7.2.3)
activestorage (= 7.2.3)
activesupport (= 7.2.3)
bundler (>= 1.15.0)
railties (= 7.1.3)
rails-dom-testing (2.2.0)
railties (= 7.2.3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.1.3)
actionpack (= 7.1.3)
activesupport (= 7.1.3)
irb
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.2.3)
actionpack (= 7.2.3)
activesupport (= 7.2.3)
cgi
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rake (13.1.0)
rdoc (6.6.2)
rake (13.3.1)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
regexp_parser (2.9.0)
reline (0.4.2)
tsort
regexp_parser (2.11.3)
reline (0.6.3)
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)
responders (3.2.0)
actionpack (>= 7.0)
railties (>= 7.0)
rexml (3.4.4)
rubyzip (3.2.2)
sassc (2.4.0)
ffi (~> 1.9)
sassc-rails (2.1.2)
@@ -208,27 +250,40 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
selenium-webdriver (4.16.0)
securerandom (0.4.1)
selenium-webdriver (4.41.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sprockets (4.2.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.9.0-aarch64-linux-gnu)
sqlite3 (2.9.0-aarch64-linux-musl)
sqlite3 (2.9.0-arm-linux-gnu)
sqlite3 (2.9.0-arm-linux-musl)
sqlite3 (2.9.0-arm64-darwin)
sqlite3 (2.9.0-x86_64-darwin)
sqlite3 (2.9.0-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl)
stringio (3.2.0)
thor (1.5.0)
tilt (2.7.0)
timeout (0.6.0)
tsort (0.2.0)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
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,32 +291,42 @@ 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.8.0)
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.5)
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
capybara
devise
importmap-rails
minitest (< 6)
mysql2 (~> 0.5)
pg (~> 1.5)
puma (~> 6.0)
rails (~> 7.1.2)
rails (~> 7.2.3)
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

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

Before

Width:  |  Height:  |  Size: 167 B

After

Width:  |  Height:  |  Size: 152 B

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

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

Before

Width:  |  Height:  |  Size: 293 B

After

Width:  |  Height:  |  Size: 278 B

View File

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

After

Width:  |  Height:  |  Size: 148 B

View File

@@ -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="M14.06,9L15,9.94L5.92,19H5V18.08L14.06,9M17.66,3C17.41,3 17.15,3.1 16.96,3.29L15.13,5.12L18.88,8.87L20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18.17,3.09 17.92,3 17.66,3M14.06,6.19L3,17.25V21H6.75L17.81,9.94L14.06,6.19Z" /></svg>

After

Width:  |  Height:  |  Size: 316 B

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

View File

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

View File

@@ -25,24 +25,24 @@ class ApplicationController < ActionController::Base
# Turbo will reload 2nd time with HTML format and flashes will be lost.
rescue_from *ActionDispatch::ExceptionWrapper.rescue_responses.keys, with: :rescue_turbo
# Required by #respond_with (gem `responders`) used by Devise controllers.
respond_to :html, :turbo_stream
def after_sign_in_path_for(resource)
# TODO: allow setting path per-user or save last path in session and restore
units_path
end
def after_sign_out_path_for(resource)
new_user_session_path
end
protected
def current_user_disguised?
session[:revert_to_id].present?
end
def after_sign_in_path_for(scope)
if current_user.at_least(:admin)
users_path
else
edit_user_registration_path
end
end
def after_sign_out_path_for(scope)
new_user_session_path
end
class << self
attr_reader :navigation_menu_tab
def navigation_tab(name)
@@ -55,6 +55,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,16 @@
class MeasurementsController < ApplicationController
def index
@measurements = []
#@measurements = current_user.units.ordered.includes(:base, :subunits)
end
def new
@quantities = current_user.quantities.ordered
end
def create
end
def destroy
end
end

View File

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

View File

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

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

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
@@ -35,7 +37,7 @@ class UsersController < ApplicationController
end
# NOTE: limited actions availabe to :admin by design. Users are meant to
# manage their accounts by themselves through registrations. :admin
# manage their accounts by themselves through profiles. :admin
# is allowed to sign-in (disguise) as user and make changes from there.
protected

View File

@@ -1,149 +1,275 @@
module ApplicationHelper
# TODO: replace legacy content_tag with tag.tagname
class LabelledFormBuilder < ActionView::Helpers::FormBuilder
(field_helpers - [:label]).each do |selector|
class LabeledFormBuilder < ActionView::Helpers::FormBuilder
(field_helpers - [:label, :hidden_field]).each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {})
labelled_row_for(method, options) { super }
labeled_field_for(method, options) { super }
end
RUBY_EVAL
end
def select(method, choices = nil, options = {}, html_options = {})
labelled_row_for(method, options) { super }
labeled_field_for(method, options) { super }
end
def submit(value, options = {})
@template.content_tag :tr do
@template.content_tag :td, super, colspan: 2
end
end
def form_for(&block)
@template.content_tag(:table, class: "centered") { yield(self) } +
# Display leftover error messages (there shouldn't be any)
@template.content_tag(:div, @object&.errors.full_messages.join(@template.tag :br))
def submit(value = nil, options = {})
value, options = nil, value if value.is_a?(Hash)
options[:class] = @template.class_names('button', options[:class])
super
end
private
def labelled_row_for(method, options)
@template.content_tag :tr do
@template.content_tag(:td, label_for(method, options), class: "unwrappable") +
@template.content_tag(:td, options.delete(:readonly) ? @object.public_send(method) : yield,
@object&.errors[method].present? ?
{class: "error", data: {content: @object&.errors.delete(method).join(" and ")}} :
{})
def labeled_field_for(method, options)
field = if options.delete(:readonly) then
value = object.public_send(method)
value = @template.l(value) if value.respond_to?(:strftime)
value ||= options[:placeholder]
else
yield
end
label_for(method, options) + field
end
def label_for(method, options = {})
return "" if (options[:label] == false)
text = options.delete(:label)
text ||= @object.class.human_attribute_name(method).capitalize
classes = @template.class_names(required: options[:required],
error: @object&.errors[method].present?)
error: object.errors[method].present?)
label(method, text+":", class: classes) +
(@template.tag(:br) + @template.content_tag(:em, options.delete(:hint)) if options[:hint])
handler = {missing_interpolation_argument_handler: method(:interpolation_missing)}
# Label translation search order:
# controller.action.* => helpers.label.model.* => activerecord.attributes.model.*
# First 2 levels are translated recursively.
label(method, class: classes) do |builder|
translation = I18n.config.with(**handler) { deep_translate(method, **options) }
translation.presence || "#{builder.translation}:"
end
end
def labelled_form_for(record, options = {}, &block)
options.merge! builder: LabelledFormBuilder
form_for(record, **options) { |f| f.form_for(&block) }
def interpolation_missing(key, values, string)
@template.instance_values[key.to_s] || deep_translate(key, **values)
end
# Extension to label text translation:
# * recursive translation,
# * interpolation of (_relative_) translation key names and instance variables,
# * pluralization based on any interpolable value
# TODO: add unit tests for the above
def deep_translate(key, **options)
options[:default] = [
:".#{key}",
:"helpers.label.#{@object_name}.#{key}_html",
:"helpers.label.#{@object_name}.#{key}",
""
]
# At least 1 interpolation key is required for #translate to act on
# missing interpolation arguments (i.e. call custom handler).
# Also setting `key` to nil avoids recurrent translation.
options[key] = nil
@template.t(".#{key}_html", **options) do |translation, resolved_key|
if translation.is_a?(Array) && (count = translation.to_h[:count])
@template.t(resolved_key, count: I18n.interpolate(count, **options), **options)
else
translation
end
end
end
end
def labeled_form_for(record, options = {}, &block)
extra_options = {builder: LabeledFormBuilder, html: {class: 'labeled-form'}}
form_for(record, **merge_attributes(options, extra_options), &block)
end
class TabularFormBuilder < ActionView::Helpers::FormBuilder
def initialize(...)
super(...)
@default_options.merge!(@options.slice(:form))
end
[:text_field, :password_field, :text_area].each do |selector|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {})
options[:maxlength] ||= object.class.type_for_attribute(method).limit
if object.errors.include?(method)
options[:pattern] = except_pattern(object.public_send(method), options[:pattern])
end
super
end
RUBY_EVAL
end
def number_field(method, options = {})
attr_type = object.type_for_attribute(method)
case attr_type.type
when :decimal
options[:value] = object.public_send(method)&.to_scientific
options[:step] ||= BigDecimal(10).power(-attr_type.scale)
options[:max] ||= BigDecimal(10).power(attr_type.precision - attr_type.scale) -
options[:step]
options[:min] = options[:min] == :step ? options[:step] : options[:min]
options[:min] ||= -options[:max]
options[:size] ||= attr_type.precision / 2
when :float
options[:size] ||= 6
end
super
end
def button(value = nil, options = {}, &block)
# #button does not use #objectify_options/@default_options
value, options = nil, value if value.is_a?(Hash)
options = options.merge(
@default_options.slice(:form),
class: @template.class_names('button', options[:class])
)
super
end
private
def submit_default_value
svg_name = object ? (object.persisted? ? 'update' : 'plus-circle-outline') : ''
@template.svg_tag("pictograms/#{svg_name}", super)
end
def except_pattern(value, pattern = nil)
"(?!^#{Regexp.escape(value)}$)" + (pattern || ".*")
end
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)
extra_options = {builder: TabularFormBuilder, skip_default_ids: true}
options = merge_attributes(options, extra_options)
# TODO: set error message with setCustomValidity instead of rendering to flash?
render_errors(record_object || record_name)
fields_for(record_name, record_object, **options, &block)
end
def svg_tag(source, options = {})
content_tag :svg, options do
tag.use href: image_path(source + ".svg") + "#icon"
def tabular_form_with(**options, &block)
extra_options = {builder: TabularFormBuilder, class: 'tabular-form',
html: {autocomplete: 'off'}}
form_with(**merge_attributes(options, extra_options), &block)
end
def svg_tag(source, label = nil, options = {})
label, options = nil, label if label.is_a? Hash
svg_tag = tag.svg(**options) do
tag.use(href: "#{image_path(source + ".svg")}#icon")
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],
# TODO: display users tab only if >1 user present; sole_user?/sole_admin?
['users', 'account-multiple-outline', :admin],
]
menu_tabs.map do |name, image, status, css_class|
if current_user.at_least(status)
link_to svg_tag("pictograms/#{image}") + t(".#{name}"),
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]}');"
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
send :#{method_name}, name, options, html_options, &block
end
RUBY_EVAL
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 render_errors(record)
flash.now[:alert] = record.errors.full_messages unless record.errors.empty?
def image_link_to(name, image = nil, options = nil, html_options = {})
name, html_options = link_or_button_options(:link, name, image, html_options)
link_to name, options, html_options
end
DISABLED_ATTRIBUTES = {disabled: true, aria: {disabled: true}, tabindex: -1}
def image_button_to_if(condition, name, image = nil, options = nil, html_options = {})
name, html_options = link_or_button_options(:button, name, image, html_options)
html_options = html_options.deep_merge DISABLED_ATTRIBUTES unless condition
button_to name, options, html_options
end
def image_link_to_unless_current(name, image = nil, options = nil, html_options = {})
name, html_options = link_or_button_options(:link, name, image, html_options)
# NOTE: Starting from Rails 8.1.0, below condition can be replaced with:
# current_page?(options, method: [:get, :post])
if request.path == url_for(options)
html_options = html_options.deep_merge DISABLED_ATTRIBUTES
end
link_to name, options, html_options
end
def render_errors(records)
# Conversion of flash to Array only required because of Devise
# TODO: override Devise message setting to Array()?
flash[:alert] = Array(flash[:alert])
Array(records).each { |record| flash[:alert] += record.errors.full_messages }
end
def render_flash_messages
flash.map do |entry, messages|
# Conversion of flash to Array only required because of Devise
Array(messages).map do |message|
tag.div class: "flash #{entry}" do
tag.div(sanitize(message)) + tag.button(sanitize("&times;"), tabindex: -1,
onclick: "this.parentElement.remove();")
tag.span(sanitize(message)) +
svg_tag('pictograms/close-outline', {onclick: "this.parentElement.remove()"})
end
end
end.join.html_safe
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)
html_options[:class] = class_names(
html_options[:class],
'button',
dangerous: html_options[:method] == :delete
)
result = (sign == -1 ? '-' : '')
unless coefficient == '1' && sign == 1
if coefficient.length > 1
result += coefficient.insert(1, '.')
elsif
result += coefficient
if html_options[:onclick]&.is_a? Hash
html_options[:onclick] = "return confirm('\#{html_options[:onclick][:confirm]}');"
end
if exponent != 1
result += "&times;"
link_is_local = html_options[:onclick] || html_options.dig(:data, :turbo_stream)
name = name.to_s
name += '...' if type == :link && !link_is_local
name = svg_tag("pictograms/#{image}", name) if image
[name, html_options]
end
# Like Hash#deep_merge, but aware of HTML attributes.
def merge_attributes(left, right)
left.deep_merge(right) do |key, lvalue, rvalue|
key == :class ? class_names(lvalue, rvalue) : rvalue
end
end
if exponent != 1
result += "10<sup>% d</sup>" % [exponent-1]
end
result.html_safe
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,11 @@
module QuantitiesHelper
def quantities_check_boxes(quantities)
# Closing <details> on focusout event depends on relatedTarget for internal
# actions being non-null. To ensure this, all top-layer elements of
# ::details-content must accept focus, e.g. <label> needs tabindex="-1" */
collection_check_boxes(nil, :quantity, quantities, :id, :to_s_with_depth,
include_hidden: false) do |b|
content_tag :li, b.label(tabindex: -1) { b.check_box + b.text }
end
end
end

View File

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

View File

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

View File

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

View File

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

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

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

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,124 @@
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]]
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
)
left_outer_joins(:base)
.order(parent_symbol, arel_table[:base_id].asc.nulls_first, :multiplier, :symbol)
.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: :delete_all
accepts_nested_attributes_for :readouts
has_many :quantities, dependent: :delete_all
has_many :units, dependent: :delete_all
validates :email, presence: true, uniqueness: true,
length: {maximum: type_for_attribute(:email).limit}
validates :unconfirmed_email,
length: {maximum: type_for_attribute(:unconfirmed_email).limit}
def to_s
email
end
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="flex">
<% unless unit.portable.nil? %>
<% if unit.default? %>
<%= image_button_to_if unit.portable?, t('.import'), 'download-outline',
import_default_unit_path(unit) %>
<% end %>
<% if current_user.at_least(:admin) %>
<% if unit.default? %>
<%= image_button_to_if unit.movable?, t('.delete'), 'delete-outline',
default_unit_path(unit), method: :delete %>
<% else %>
<%= image_button_to_if unit.portable?, t('.export'), 'upload-outline',
export_default_unit_path(unit) %>
<% end %>
<% end %>
<% end %>
</td>
<% end %>
<% end %>

View File

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

View File

@@ -5,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="flex">
<%= image_link_to t(".source_code"), "code-braces", source_code_url %>
<%= image_link_to t(".issue_tracker"), "bug-outline", issue_tracker_url,
class: "extendedright" %>
class: "rextend" %>
<% if user_signed_in? %>
<%= image_link_to_unless_current(current_user.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>
@@ -48,7 +47,7 @@
<%= render_flash_messages %>
</div>
<%# Allow overwriting/clearing navigation menu for some views %>
<%# Allows overwriting/clearing navigation menu for some views %>
<nav class="navigation">
<%= content_for(:navigation) || (navigation_menu if user_signed_in?) %>
</nav>

View File

@@ -0,0 +1,45 @@
<%= tabular_form_with model: Measurement.new, id: :measurement_form,
class: 'topside-area flex vertical center',
html: {onkeydown: 'formProcessKey(event)'} do |form| %>
<table class="items-table center">
<tbody id="readouts">
<%= tabular_fields_for @measurement do |form| %>
<tr class="italic">
<td class="hexpand hmin50"><%= t '.taken_at_html' %></td>
<td colspan="3" class="ralign">
<%= form.datetime_field :taken_at, required: true %>
</td>
</tr>
<% end %>
</tbody>
</table>
<%# TODO: right-click selection; unnecessary with hierarchical tags? %>
<details id="quantity_select" class="center hexpand" open
onkeydown="detailsProcessKey(event)">
<summary autofocus>
<!-- TODO: Set content with CSS when span empty to avoid duplication -->
<span data-prompt="<%= t('.select_quantity') %>">
<%= t('.select_quantity') %>
</span>
<%= image_button_tag t(:apply), "update", name: nil, disabled: true,
formaction: new_readout_path, formmethod: :get, formnovalidate: true,
data: {turbo_stream: true} %>
</summary>
<ul><%= quantities_check_boxes(@quantities) %></ul>
</details>
<div class="flex reverse">
<%= form.button id: :create_measurement_button, disabled: true -%>
<%= image_link_to t(:cancel), "close-outline", measurements_path, name: :cancel,
class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</div>
<% end %>
<script>
quantity_select.addEventListener('focusout', detailsClose)
quantity_select.addEventListener('change', detailsChange)
detailsObserver.observe(quantity_select.querySelector('ul'),
{subtree: true, attributeFilter: ['disabled']})
</script>

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
<%= turbo_stream.disable :new_measurement_link -%>
<%= turbo_stream.hide :no_items -%>
<%= turbo_stream.append_all 'body' 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: "formProcessKey(event)",
data: {link: link, form: form_tag, hidden_row: hidden_row} do %>
<td style="--depth:<%= @quantity.depth %>">
<%= form.text_field :name, required: true, autofocus: true, size: 20 %>
</td>
<td>
<%= form.text_area :description, cols: 30, rows: 1, escape: false %>
</td>
<td class="flex">
<%= form.button %>
<%= image_link_to t(:cancel), "close-outline", quantities_path, class: 'dangerous',
name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %>
</td>
<td></td>
<% end %>
<% end %>

View File

@@ -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 style="--depth:<%= quantity.depth %>">
<%= link_to quantity, edit_quantity_path(quantity), class: 'link',
onclick: 'this.blur();', data: {turbo_stream: true} %>
</td>
<td><%= quantity.description %></td>
<% if current_user.at_least(:active) %>
<td class="flex">
<%= image_link_to t('.new_subquantity'), 'plus-outline', new_quantity_path(quantity),
id: dom_id(quantity, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>
<%= image_button_to_if quantity.destroyable?, t('.destroy'), 'delete-outline',
quantity_path(quantity), method: :delete %>
</td>
<td class="handle" draggable="true">&#x283F</td>
<% end %>
<% end %>

View File

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

View File

@@ -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,24 @@
<%# TODO: add readout reordering by dragging %>
<%= tabular_fields_for 'readouts[]', readout do |form| %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout) do %>
<td>
<%# TODO: add grayed readout index (in separate column?) %>
<%= readout.quantity.relative_pathname(@superquantity) %>
<%= form.hidden_field :quantity_id %>
</td>
<td>
<%= form.number_field :value, required: true, autofocus: readout_counter == 0 %>
</td>
<td>
<%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) },
{prompt: '', disabled: '', selected: ''}, required: true %>
</td>
<td class="flex">
<%# TODO: change to _link_ after giving up displaying relative paths %>
<%= image_button_tag '', 'delete-outline', class: 'dangerous', name: nil,
formaction: discard_readouts_path(readout.quantity),
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>
</td>
<% end %>
<% end %>

View File

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

View File

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

View File

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

View File

@@ -1,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: "formProcessKey(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 %>
<%= 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}) %>
<td class="flex">
<%= form.button %>
<%= image_link_to t(:cancel), "close-outline", units_path, class: 'dangerous',
name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %>
</td>
<td></td>
<% 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,26 @@
<%= 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 style="--depth:<%= unit.base_id? ? 1 : 0 %>">
<%= link_to unit, edit_unit_path(unit), class: 'link', 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="ralign"><%= 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} %>
<td class="flex">
<% unless unit.base_id? %>
<%= image_link_to t('.new_subunit'), 'plus-outline', new_unit_path(unit),
id: dom_id(unit, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>
<% 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,29 +1,30 @@
<div class="rightside buttongrid">
<div class="rightside-area 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-area' %>
</div>
<%# TODO: remove? form can be inserted directly, e.g. at the end of index %>
<%= tag.div id: :unit_form %>
<table class="main items">
<table class="main-area items-table">
<thead>
<tr>
<th><%= User.human_attribute_name(:symbol).capitalize %></th>
<th><%= User.human_attribute_name(:name).capitalize %></th>
<th><%= User.human_attribute_name(:multiplier).capitalize %></th>
<th><%= Unit.human_attribute_name(:symbol) %></th>
<th class="hexpand"><%= Unit.human_attribute_name(:description) %></th>
<th><%= Unit.human_attribute_name(:multiplier) %></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 +32,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

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
<p>Hello <%= @resource.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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@
<%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>
<div class="actions">
<div class="flex">
<%= f.submit "Resend unlock instructions" %>
</div>
<% end %>

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'
@@ -90,7 +91,7 @@ Devise.setup do |config|
# It will change confirmation, password recovery and other workflows
# to behave the same regardless if the e-mail provided was right or wrong.
# Does not affect registerable.
# config.paranoid = true
config.paranoid = true
# By default Devise will store the user in session. You can skip storage for
# particular strategies by setting this option.

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