Compare commits

..

1 Commits

Author SHA1 Message Date
0daf413b47 Prevent sole admin from deleting their account
Without this guard, the last admin in the system could delete their own
account, making the application unmanageable. This adds a model method
`User#sole_admin?`, a controller guard in `RegistrationsController#destroy`,
and disables the delete button in the profile edit view when the current
user is the only remaining admin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 06:52:14 +00:00
45 changed files with 404 additions and 674 deletions

View File

@@ -1,74 +0,0 @@
name: Tests
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
test-sqlite:
name: Tests (SQLite)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
env:
BUNDLE_WITH: "sqlite:development:test"
- name: Set up test database
run: bin/rails db:create db:schema:load
env:
RAILS_ENV: test
- name: Run tests
run: bin/rails test
env:
RAILS_ENV: test
CI: "true"
test-mysql:
name: Tests (MySQL)
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: ""
MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
MYSQL_DATABASE: fixin_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true
env:
BUNDLE_WITH: "mysql:development:test"
- name: Set up test database
run: bin/rails db:schema:load
env:
RAILS_ENV: test
DB_ADAPTER: mysql
- name: Run tests
run: bin/rails test
env:
RAILS_ENV: test
CI: "true"
DB_ADAPTER: mysql

View File

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

Before

Width:  |  Height:  |  Size: 152 B

After

Width:  |  Height:  |  Size: 167 B

View File

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

Before

Width:  |  Height:  |  Size: 278 B

After

Width:  |  Height:  |  Size: 293 B

View File

@@ -18,14 +18,10 @@
/* Strive for simplicity: /* Strive for simplicity:
* * style elements/tags only - if possible, * * style elements/tags only - if possible,
* * replace element/tag name with class name - if element has to be styled * * replace element/tag name with class name - if element has to be styled
* differently depending on context (e.g. <form>; <a> as link/button), * differently depending on context (e.g. form)
* * 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, * NOTE: Style in a modular way, similar to how CSS @scope would be used,
* to make transition easier once @scope is widely available. */ * to make transition easier once @scope is widely available */
/* TODO: review styles with multiple selectors and try to convert them to the same
* specificity. */
:root { :root {
--color-focus-gray: #f3f3f3; --color-focus-gray: #f3f3f3;
--color-border-gray: #dddddd; --color-border-gray: #dddddd;
@@ -58,16 +54,11 @@
outline: none; outline: none;
} }
/* [hidden] submit elements cannot have `display` set as it makes them visible. */
[hidden] {
display: none !important;
}
/* Color coding of input controls' background: /* Color coding of input controls' background:
* blue - target for interaction with pointer, * blue - target for interaction with pointer
* gray - target for interaction with keyboard, * gray - target for interaction with keyboard
* red - destructive, non-undoable action. * red - destructive, non-undoable action
*/ */
button, button,
details, details,
@@ -82,24 +73,50 @@ input,
select { select {
text-align: inherit; text-align: inherit;
} }
a,
button,
input[type=submit] {
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
/* [hidden] submit controls cannot have `display` set as it makes them visible */
.button,
button:not([hidden]),
input[type=submit]:not([hidden]),
.tab {
align-items: center;
color: var(--color-gray);
display: flex;
fill: var(--color-gray);
font-weight: bold;
}
.button,
button,
input[type=submit] {
font-size: 0.8rem;
padding: 0.6em 0.5em;
width: fit-content;
}
input:not([type=submit]):not([type=checkbox]),
select,
summary,
textarea {
padding: 0.2em 0.4em;
}
.button,
button,
input, input,
select, select,
summary, summary,
textarea { textarea {
border: 1px solid var(--color-gray); border: solid 1px var(--color-gray);
border-radius: 0.25em; border-radius: 0.25em;
padding: 0.2em 0.4em;
}
[name=cancel],
.auxiliary {
border-color: var(--color-border-gray);
color: var(--color-nav-gray);
fill: var(--color-nav-gray);
} }
input[type=checkbox], input[type=checkbox],
svg, svg,
textarea { textarea {
margin: 0; margin: 0
} }
input[type=checkbox] { input[type=checkbox] {
accent-color: var(--color-blue); accent-color: var(--color-blue);
@@ -107,8 +124,6 @@ input[type=checkbox] {
-webkit-appearance: none; -webkit-appearance: none;
display: flex; display: flex;
height: 1.1em; height: 1.1em;
margin: 0;
padding: 0;
width: 1.1em; width: 1.1em;
} }
input[type=checkbox]:checked { input[type=checkbox]:checked {
@@ -116,7 +131,6 @@ input[type=checkbox]:checked {
-webkit-appearance: checkbox; -webkit-appearance: checkbox;
} }
/* Hide spin buttons in input number fields */ /* Hide spin buttons in input number fields */
/* TODO: add spin buttons inside input[number]: before (-) and after (+) input */
input[type=number] { input[type=number] {
appearance: textfield; appearance: textfield;
-moz-appearance: textfield; -moz-appearance: textfield;
@@ -128,80 +142,37 @@ input::-webkit-outer-spin-button {
-webkit-appearance: none; -webkit-appearance: none;
margin: 0; margin: 0;
} }
.button > svg,
.tab > svg,
button > svg {
height: 1.4em;
width: 1.4em;
}
.button > svg:not(:last-child),
.tab > svg:not(:last-child),
button > svg:not(:last-child) {
margin-right: 0.2em;
}
/* TODO: move normal non-button links (<a>:hover/:focus) styling here (i.e.
* page-wide, top-level) and remove from table.items - as the style should be
* same everywhere */
.button:focus-visible,
button:focus-visible,
input[type=submit]:focus-visible {
background-color: var(--color-focus-gray);
}
input:focus-visible, input:focus-visible,
select:focus-visible, select:focus-visible,
select:focus-within, select:focus-within,
/* TODO: how to achieve `summary:focus-within` for `::details-content`? */ /* TODO: how to achieve summary:focus-within for ::details-content? */
summary:focus-visible, summary:focus-visible,
textarea:focus-visible { textarea:focus-visible {
accent-color: var(--color-dark-blue); accent-color: var(--color-dark-blue);
background-color: var(--color-focus-gray); background-color: var(--color-focus-gray);
} }
input:hover, .button:hover,
select:hover, button:hover,
summary:hover, input[type=submit]:hover {
textarea:hover {
border-color: var(--color-blue);
outline: 1px solid var(--color-blue);
}
select:hover,
summary:hover {
cursor: pointer;
}
input:invalid,
select:invalid,
textarea:invalid {
border-color: var(--color-red);
outline-color: var(--color-red);
}
/* `.button`: button-styled <a>, <button>, <input type=submit>.
* `.link`: any other <a>.
* `.tab`: tab-styled <a>.
*/
.button,
.link,
.tab {
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.button,
.tab {
align-items: center;
color: var(--color-gray);
display: flex;
fill: var(--color-gray);
font-weight: bold;
}
.button {
border: 1px solid var(--color-gray);
border-radius: 0.25em;
font-size: 0.8rem;
padding: 0.6em 0.5em;
width: fit-content;
}
[name=cancel],
.auxiliary {
border-color: var(--color-border-gray);
color: var(--color-nav-gray);
fill: var(--color-nav-gray);
}
.button > svg,
.tab > svg {
height: 1.4em;
width: 1.4em;
}
.button > svg:not(:last-child),
.tab > svg:not(:last-child) {
margin-right: 0.2em;
}
.button:focus-visible,
.tab:focus-visible,
.tab:hover {
background-color: var(--color-focus-gray);
}
.button:hover {
background-color: var(--color-blue); background-color: var(--color-blue);
border-color: var(--color-blue); border-color: var(--color-blue);
color: white; color: white;
@@ -211,9 +182,23 @@ textarea:invalid {
background-color: var(--color-red); background-color: var(--color-red);
border-color: var(--color-red); border-color: var(--color-red);
} }
/* TODO: move normal, non-button links (<a>:hover/:focus) styling here (i.e. input:hover,
* page-wide, top-level) and remove from `table.items` - as the style should be select:hover,
* same everywhere. */ summary:hover,
textarea:hover {
border-color: var(--color-blue);
outline: solid 1px var(--color-blue);
}
select:hover,
summary:hover {
cursor: pointer;
}
input:invalid,
select:invalid,
textarea:invalid {
border-color: var(--color-red);
outline: solid 1px var(--color-red);
}
input[type=text]:read-only, input[type=text]:read-only,
textarea:read-only { textarea:read-only {
border: none; border: none;
@@ -221,8 +206,8 @@ textarea:read-only {
} }
/* NOTE: collapse gaps around empty rows (`topside`) once possible with /* NOTE: collapse gaps around empty rows (`topside`) once possible
* `grid-collapse` property and remove alternative `grid-template-areas`. * with grid-collapse property and remove alternative grid-template
* https://github.com/w3c/csswg-drafts/issues/5813 */ * https://github.com/w3c/csswg-drafts/issues/5813 */
body { body {
display: grid; display: grid;
@@ -255,14 +240,18 @@ header {
margin-inline-start: 4%; margin-inline-start: 4%;
} }
.navigation > .tab { .navigation > .tab {
border-bottom: 2px solid var(--color-nav-gray); border-bottom: solid 2px var(--color-nav-gray);
flex: 1; flex: 1;
font-size: 1rem; font-size: 1rem;
justify-content: center; justify-content: center;
padding-block: 0.4em; padding-block: 0.4em;
} }
.navigation > .tab:hover,
.navigation > .tab:focus-visible {
background-color: var(--color-focus-gray);
}
.navigation > .tab.active { .navigation > .tab.active {
border-bottom: 4px solid var(--color-blue); border-bottom: solid 4px var(--color-blue);
color: var(--color-blue); color: var(--color-blue);
fill: var(--color-blue); fill: var(--color-blue);
} }
@@ -294,7 +283,7 @@ header {
#flashes { #flashes {
display: grid; display: grid;
row-gap: 0.4em; gap: 0.2em;
grid-template-columns: 1fr auto auto auto 1fr; grid-template-columns: 1fr auto auto auto 1fr;
left: 0; left: 0;
pointer-events: none; pointer-events: none;
@@ -310,17 +299,13 @@ header {
display: grid; display: grid;
grid-column: 2/5; grid-column: 2/5;
grid-template-columns: subgrid; grid-template-columns: subgrid;
line-height: 2.2em;
pointer-events: auto; pointer-events: auto;
} }
.flash:before {
filter: invert();
height: 1.4em;
margin: 0 0.5em;
width: 1.4em;
}
.flash.alert:before { .flash.alert:before {
content: url('pictograms/alert-outline.svg'); content: url('pictograms/alert-outline.svg');
height: 1.4em;
margin: 0 0.5em;
width: 1.4em;
} }
.flash.alert { .flash.alert {
border-color: var(--color-red); border-color: var(--color-red);
@@ -328,24 +313,33 @@ header {
} }
.flash.notice:before { .flash.notice:before {
content: url('pictograms/check-circle-outline.svg'); content: url('pictograms/check-circle-outline.svg');
height: 1.4em;
margin: 0 0.5em;
width: 1.4em;
} }
.flash.notice { .flash.notice {
border-color: var(--color-blue); border-color: var(--color-blue);
background-color: var(--color-blue); background-color: var(--color-blue);
} }
.flash svg { .flash > div {
cursor: pointer; grid-column: 2;
fill: white;
height: 2.2em;
opacity: 0.6;
padding: 0.4em 0.5em;
width: 2.4em;
} }
.flash svg:hover { /* NOTE: currently flash button inherits some unnecessary styles from generic
* button. */
.flash > button {
border: none;
color: inherit;
cursor: pointer;
font-size: 1.4em;
font-weight: bold;
grid-column: 3;
opacity: 0.6;
padding: 0.2em 0.4em;
}
.flash > button:hover {
opacity: 1; opacity: 1;
} }
/* TODO: Hover over invalid should work like in measurements (thin vs thick border) */ /* TODO: Hover over invalid should work like in measurements (thin vs thick border) */
.labeled-form { .labeled-form {
align-items: center; align-items: center;
@@ -363,7 +357,7 @@ header {
.labeled-form label.required { .labeled-form label.required {
font-weight: bold; font-weight: bold;
} }
/* Don't style `label.error + input` if case already covered by `input:invalid`. */ /* Don't style `label.error + input` if case already covered by input:invalid */
.labeled-form label.error { .labeled-form label.error {
color: var(--color-red); color: var(--color-red);
} }
@@ -377,23 +371,15 @@ header {
} }
.labeled-form input[type=submit] { .labeled-form input[type=submit] {
font-size: 1rem; font-size: 1rem;
margin: 1em auto 0 auto; margin: 1.5em auto 0 auto;
padding: 0.75em; 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;
}
/* TODO: remove `.items` class (?) and make `form table` work properly. */ /* TODO: remove .items class (?) and make 'form table' work properly */
table.items { table.items {
border-spacing: 0; border-spacing: 0;
border: 1px solid var(--color-border-gray); border: solid 1px var(--color-border-gray);
border-radius: 0.25em; border-radius: 0.25em;
font-size: 0.85rem; font-size: 0.85rem;
text-align: left; text-align: left;
@@ -416,7 +402,7 @@ table.items th,
table.items td { table.items td {
padding-inline: 1em 0; padding-inline: 1em 0;
} }
/* For <a> to fill <td> completely, we use an `::after` pseudoelement. */ /* For <a> to fill <td> completely, we use an ::after pseudoelement. */
table.items td.link { table.items td.link {
padding: 0; padding: 0;
position: relative; position: relative;
@@ -446,7 +432,7 @@ table.items td:last-child {
padding-inline-end: 0.1em; padding-inline-end: 0.1em;
} }
table.items td { table.items td {
border-top: 1px solid var(--color-border-gray); border-top: solid 1px var(--color-border-gray);
height: 2.4em; height: 2.4em;
padding-block: 0.1em; padding-block: 0.1em;
} }
@@ -465,7 +451,7 @@ table.items tr.dropzone::after {
content: ''; content: '';
inset: 1px 0 0 0; inset: 1px 0 0 0;
position: absolute; position: absolute;
outline: 2px dashed var(--color-blue); outline: dashed 2px var(--color-blue);
outline-offset: -1px; outline-offset: -1px;
z-index: var(--z-index-table-row-outline); z-index: var(--z-index-table-row-outline);
} }
@@ -476,8 +462,8 @@ table.items tr.form td {
vertical-align: top; vertical-align: top;
} }
/* TODO: replace `:hover:focus-visible` combos with proper LOVE style order. */ /* TODO: replace :hover:focus-visible combos with proper LOVE stye order */
/* TODO: update table styling: simplify selectors, deduplicate, remove non-font rem. */ /* TODO: Update table styling: simplify selectors, deduplicate, remove non-font rem. */
table.items td.link a:hover, table.items td.link a:hover,
table.items td.link a:focus-visible, table.items td.link a:focus-visible,
table.items td.link a:hover:focus-visible { table.items td.link a:hover:focus-visible {
@@ -515,7 +501,9 @@ table.items td.svg {
table.items td.number { table.items td.number {
text-align: right; text-align: right;
} }
table.items .button { table.items .button,
table.items button,
table.items input[type=submit] {
font-weight: normal; font-weight: normal;
height: 100%; height: 100%;
padding: 0.4em; padding: 0.4em;
@@ -525,27 +513,30 @@ table.items select,
table.items textarea { table.items textarea {
padding-block: 0.375em; padding-block: 0.375em;
} }
/* TODO: find a way (layers?) to style inputs differently while making sure
table input, * hover works properly without using :not(:hover) selectors here. */
table select, table.items .button:not(:hover),
table summary, table.items button:not(:hover),
table textarea { table.items input:not(:hover),
table.items select:not(:hover),
table.items textarea:not(:hover) {
border-color: var(--color-border-gray); border-color: var(--color-border-gray);
} }
table select { 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); color: var(--color-table-gray);
} }
table select:hover, table.items select:focus-within,
table select:focus-within, table.items select:focus-visible {
table select:focus-visible {
color: black; color: black;
} }
table .button { form a[name=cancel] {
border-color: var(--color-border-gray); border-color: var(--color-border-gray);
color: var(--color-table-gray); color: var(--color-nav-gray);
fill: var(--color-nav-gray);
} }
form table.items { form table.items {
border: none; border: none;
} }
@@ -557,9 +548,6 @@ form table.items td {
form table.items td:first-child { form table.items td:first-child {
color: inherit; color: inherit;
} }
form table select {
color: black;
}
.centered { .centered {
@@ -575,17 +563,34 @@ form table select {
display: flex; display: flex;
gap: 0.8em; gap: 0.8em;
} }
.hflex.reverse {
flex-direction: row-reverse;
}
.hflex.centered { .hflex.centered {
justify-content: center; justify-content: center;
} }
.vexpand { .hint {
width: 100%; color: var(--color-table-gray);
font-style: italic;
font-size: 0.9rem;
text-align: center;
} }
.vflex { .vflex {
display: flex; display: flex;
gap: 0.8em; gap: 0.8em;
flex-direction: column; flex-direction: column;
} }
[disabled] {
/* label:has(input[disabled]) {
* TODO: disabled checkbox blue square focus removal; disabled label styling;
* focused label styling (currently only checkbox has focus)
* */
border-color: var(--color-border-gray) !important;
color: var(--color-border-gray) !important;
cursor: not-allowed;
fill: var(--color-border-gray) !important;
pointer-events: none;
}
details { details {
@@ -613,7 +618,7 @@ summary:has(.button) {
padding-inline-end: 0; padding-inline-end: 0;
} }
summary .button { summary .button {
border: 1px solid var(--color-border-gray); border: solid 1px var(--color-border-gray);
border-radius: inherit; border-radius: inherit;
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
@@ -629,10 +634,10 @@ details[open] summary::before {
summary::marker { summary::marker {
padding-left: 0.25em; padding-left: 0.25em;
} }
/* NOTE: use `details[open]::details-content` once widely available. */ /* NOTE: use details[open]::details-content once widely available */
details[open] ul { details[open] ul {
background: white; background: white;
border: 1px solid var(--color-border-gray); border: solid 1px var(--color-border-gray);
border-radius: 0.25em; border-radius: 0.25em;
box-shadow: 1px 1px 3px var(--color-border-gray); box-shadow: 1px 1px 3px var(--color-border-gray);
margin: -1px 0 0 0; margin: -1px 0 0 0;
@@ -654,10 +659,3 @@ li input[type=checkbox] {
li::marker { li::marker {
content: ''; 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.
* */

View File

@@ -26,18 +26,6 @@ class ApplicationController < ActionController::Base
# Turbo will reload 2nd time with HTML format and flashes will be lost. # Turbo will reload 2nd time with HTML format and flashes will be lost.
rescue_from *ActionDispatch::ExceptionWrapper.rescue_responses.keys, with: :rescue_turbo 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 protected
def current_user_disguised? def current_user_disguised?

View File

@@ -1,13 +1,7 @@
class MeasurementsController < ApplicationController class MeasurementsController < ApplicationController
before_action except: :index do
raise AccessForbidden unless current_user.at_least(:active)
end
def index def index
readouts = current_user.readouts.includes(:quantity, :unit).order(created_at: :desc) @measurements = []
@measurements = readouts.group_by(&:created_at).map do |created_at, grouped| #@measurements = current_user.units.ordered.includes(:base, :subunits)
Measurement.new(created_at: created_at, readouts: grouped)
end
end end
def new def new
@@ -15,33 +9,8 @@ class MeasurementsController < ApplicationController
end end
def create def create
timestamp = Time.current
@readouts = readout_params.map do |rp|
r = current_user.readouts.new(rp)
r.created_at = timestamp
r
end
if @readouts.all?(&:valid?)
Readout.transaction { @readouts.each(&:save!) }
@measurement = Measurement.new(readouts: @readouts, created_at: timestamp)
flash.now[:notice] = t('.success')
else
render :new, status: :unprocessable_entity
end
end end
def destroy def destroy
@measurement = Measurement.new(id: params[:id].to_i,
created_at: Time.at(params[:id].to_i))
current_user.readouts.where(created_at: @measurement.created_at).delete_all
@measurements_empty = current_user.readouts.empty?
flash.now[:notice] = t('.success')
end
private
def readout_params
params.require(:readouts).map { |r| r.permit(:quantity_id, :value, :unit_id) }
end end
end end

View File

@@ -1,23 +1,16 @@
class ReadoutsController < ApplicationController class ReadoutsController < ApplicationController
before_action :find_quantity, only: [:new, :discard] before_action :find_quantities, only: [:new]
before_action :find_quantity, only: [:discard]
before_action :find_prev_quantities, only: [:new, :discard] before_action :find_prev_quantities, only: [:new, :discard]
def new def new
new_quantities = @quantities -= @prev_quantities
case params[:button] # TODO: raise ParameterInvalid if new_quantities.empty?
when 'children' @readouts = current_user.readouts.build(@quantities.map { |q| {quantity: q} })
@quantity.subquantities
when 'subtree'
@quantity.progenies
else
[@quantity]
end
new_quantities -= @prev_quantities
@readouts = current_user.readouts.build(new_quantities.map { |q| {quantity: q} })
@user_units = current_user.units.ordered @user_units = current_user.units.ordered
quantities = @prev_quantities + new_quantities quantities = @prev_quantities + @quantities
@superquantity = current_user.quantities @superquantity = current_user.quantities
.common_ancestors(quantities.map(&:parent_id)).first .common_ancestors(quantities.map(&:parent_id)).first
end end
@@ -31,6 +24,9 @@ class ReadoutsController < ApplicationController
private private
def find_quantities
@quantities = current_user.quantities.find(params[:quantity])
end
def find_quantity def find_quantity
@quantity = current_user.quantities.find_by!(id: params[:id]) @quantity = current_user.quantities.find_by!(id: params[:id])

View File

@@ -1,4 +1,6 @@
class User::ProfilesController < Devise::RegistrationsController class RegistrationsController < Devise::RegistrationsController
before_action :authenticate_user!, only: [:edit, :update, :destroy]
def destroy def destroy
if current_user.sole_admin? if current_user.sole_admin?
redirect_back fallback_location: edit_user_registration_path, redirect_back fallback_location: edit_user_registration_path,

View File

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

View File

@@ -12,12 +12,6 @@ module ApplicationHelper
labeled_field_for(method, options) { super } labeled_field_for(method, options) { super }
end end
def submit(value = nil, options = {})
value, options = nil, value if value.is_a?(Hash)
options[:class] = @template.class_names('button', options[:class])
super
end
private private
def labeled_field_for(method, options) def labeled_field_for(method, options)
@@ -78,8 +72,13 @@ module ApplicationHelper
end end
def labeled_form_for(record, options = {}, &block) def labeled_form_for(record, options = {}, &block)
extra_options = {builder: LabeledFormBuilder, html: {class: 'labeled-form'}} extra_options = {builder: LabeledFormBuilder,
form_for(record, **merge_attributes(options, extra_options), &block) data: {turbo: false},
html: {class: 'labeled-form'}}
options = options.deep_merge(extra_options) do |key, left, right|
key == :class ? class_names(left, right) : right
end
form_for(record, **options, &block)
end end
class TabularFormBuilder < ActionView::Helpers::FormBuilder class TabularFormBuilder < ActionView::Helpers::FormBuilder
@@ -114,12 +113,8 @@ module ApplicationHelper
end end
def button(value = nil, options = {}, &block) def button(value = nil, options = {}, &block)
# #button does not use #objectify_options/@default_options # button does not use #objectify_options
value, options = nil, value if value.is_a?(Hash) options.merge!(@options.slice(:form))
options = options.merge(
@default_options.slice(:form),
class: @template.class_names('button', options[:class])
)
super super
end end
@@ -140,21 +135,20 @@ module ApplicationHelper
# [autofocus]. Otherwise IDs are not unique when multiple forms are open # [autofocus]. Otherwise IDs are not unique when multiple forms are open
# and the first input gets focus. # and the first input gets focus.
record_object, options = nil, record_object if record_object.is_a?(Hash) record_object, options = nil, record_object if record_object.is_a?(Hash)
extra_options = {builder: TabularFormBuilder, skip_default_ids: true} options.merge!(builder: TabularFormBuilder, skip_default_ids: true)
options = merge_attributes(options, extra_options)
# TODO: set error message with setCustomValidity instead of rendering to flash? # TODO: set error message with setCustomValidity instead of rendering to flash?
render_errors(record_object || record_name) render_errors(record_object || record_name)
fields_for(record_name, record_object, **options, &block) fields_for(record_name, record_object, **options, &block)
end end
def tabular_form_with(**options, &block) def tabular_form_with(**options, &block)
extra_options = {builder: TabularFormBuilder, html: {autocomplete: 'off'}} options = options.deep_merge(builder: TabularFormBuilder,
form_with(**merge_attributes(options, extra_options), &block) html: {autocomplete: 'off'})
form_with(**options, &block)
end end
def svg_tag(source, label = nil, options = {}) def svg_tag(source, label = nil, options = {})
label, options = nil, label if label.is_a? Hash svg_tag = tag.svg(options) do
svg_tag = tag.svg(**options) do
tag.use(href: "#{image_path(source + ".svg")}#icon") tag.use(href: "#{image_path(source + ".svg")}#icon")
end end
label.blank? ? svg_tag : svg_tag + tag.span(label) label.blank? ? svg_tag : svg_tag + tag.span(label)
@@ -165,7 +159,6 @@ module ApplicationHelper
['measurements', 'scale-bathroom', :restricted], ['measurements', 'scale-bathroom', :restricted],
['quantities', 'axis-arrow', :restricted, 'right'], ['quantities', 'axis-arrow', :restricted, 'right'],
['units', 'weight-gram', :restricted], ['units', 'weight-gram', :restricted],
# TODO: display users tab only if >1 user present; sole_user?/sole_admin?
['users', 'account-multiple-outline', :admin], ['users', 'account-multiple-outline', :admin],
] ]
@@ -213,7 +206,6 @@ module ApplicationHelper
def render_errors(records) def render_errors(records)
# Conversion of flash to Array only required because of Devise # Conversion of flash to Array only required because of Devise
# TODO: override Devise message setting to Array()?
flash[:alert] = Array(flash[:alert]) flash[:alert] = Array(flash[:alert])
Array(records).each { |record| flash[:alert] += record.errors.full_messages } Array(records).each { |record| flash[:alert] += record.errors.full_messages }
end end
@@ -223,8 +215,8 @@ module ApplicationHelper
# Conversion of flash to Array only required because of Devise # Conversion of flash to Array only required because of Devise
Array(messages).map do |message| Array(messages).map do |message|
tag.div class: "flash #{entry}" do tag.div class: "flash #{entry}" do
tag.span(sanitize(message)) + tag.div(sanitize(message)) + tag.button(sanitize("&times;"), tabindex: -1,
svg_tag('pictograms/close-outline', {onclick: "this.parentElement.remove()"}) onclick: "this.parentElement.remove();")
end end
end end
end.join.html_safe end.join.html_safe
@@ -260,11 +252,4 @@ module ApplicationHelper
[name, html_options] [name, html_options]
end end
# Like Hash#deep_merge, but aware of HTML attributes.
def merge_attributes(left, right)
left.deep_merge(right) do |key, lvalue, rvalue|
key == :class ? class_names(lvalue, rvalue) : rvalue
end
end
end end

View File

@@ -37,18 +37,6 @@ window.detailsObserver = new MutationObserver((mutations) => {
mutations[0].target.dispatchEvent(new Event('change', {bubbles: true})) 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 stream actions */
Turbo.StreamElement.prototype.disableElement = function(element) { Turbo.StreamElement.prototype.disableElement = function(element) {

View File

@@ -1,17 +1,3 @@
class Measurement class Measurement
include ActiveModel::Model include ActiveModel::Model
attr_accessor :readouts, :created_at
def id
created_at.to_i
end
def to_param
id.to_s
end
def persisted?
true
end
end end

View File

@@ -61,8 +61,8 @@ class Quantity < ApplicationRecord
# Return: ordered [sub]hierarchy # Return: ordered [sub]hierarchy
scope :ordered, ->(root: nil, include_root: true) { scope :ordered, ->(root: nil, include_root: true) {
if connection.adapter_name =~ /mysql/i
numbered = Arel::Table.new('numbered') numbered = Arel::Table.new('numbered')
self.model.with(numbered: numbered(:parent_id, :name)).with_recursive(arel_table.name => [ self.model.with(numbered: numbered(:parent_id, :name)).with_recursive(arel_table.name => [
numbered.project( numbered.project(
numbered[Arel.star], numbered[Arel.star],
@@ -73,14 +73,6 @@ class Quantity < ApplicationRecord
arel_table[:path].concat(numbered[:child_number]) arel_table[:path].concat(numbered[:child_number])
).join(arel_table).on(numbered[:parent_id].eq(arel_table[:id])) ).join(arel_table).on(numbered[:parent_id].eq(arel_table[:id]))
]).order(arel_table[:path]) ]).order(arel_table[:path])
elsif root.nil?
# SQLite: pathname column already stores the full hierarchical path
order(:pathname)
else
root_pathname = unscoped.where(id: root).pick(:pathname)
scope = order(:pathname).where("pathname LIKE ?", "#{root_pathname}#{PATHNAME_DELIMITER}%")
include_root ? scope.or(where(id: root)) : scope
end
} }
# TODO: extract named functions to custom Arel extension # TODO: extract named functions to custom Arel extension

View File

@@ -26,8 +26,10 @@ class Unit < ApplicationRecord
other_bases_units = arel_table.alias('other_bases_units') other_bases_units = arel_table.alias('other_bases_units')
sub_units = arel_table.alias('sub_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_recursive(actionable_units: [
self.or(Unit.defaults).left_joins(:base) Unit.with(units: self.or(Unit.defaults)).left_joins(:base)
.where.not( .where.not(
# Exclude Units that are/have default counterpart # Exclude Units that are/have default counterpart
Arel::SelectManager.new.project(1).from(other_units) Arel::SelectManager.new.project(1).from(other_units)
@@ -63,14 +65,8 @@ class Unit < ApplicationRecord
), ),
# Fill base Units to display proper hierarchy. Duplicates will be removed # Fill base Units to display proper hierarchy. Duplicates will be removed
# by final group() - can't be deduplicated with UNION due to 'portable' field. # by final group() - can't be deduplicated with UNION due to 'portable' field.
# Use ActiveRecord::Relation (not a raw SelectManager) so the SQLite Arel arel_table.join(actionable_units).on(actionable_units[:base_id].eq(arel_table[:id]))
# visitor does not wrap it in parentheses inside the UNION ALL CTE body. .project(arel_table[Arel.star], Arel::Nodes.build_quoted(nil).as('portable'))
Unit.joins(
arel_table.create_join(
actionable_units,
arel_table.create_on(actionable_units[:base_id].eq(arel_table[:id]))
)
).select(arel_table[Arel.star], Arel::Nodes.build_quoted(nil).as('portable'))
]).select(units: [:base_id, :symbol]) ]).select(units: [:base_id, :symbol])
.select( .select(
units[:id].minimum.as('id'), # can be ANY_VALUE() units[:id].minimum.as('id'), # can be ANY_VALUE()
@@ -78,7 +74,7 @@ class Unit < ApplicationRecord
Arel::Nodes.build_quoted(1).as('multiplier'), # disregard multiplier when sorting Arel::Nodes.build_quoted(1).as('multiplier'), # disregard multiplier when sorting
units[:portable].minimum.as('portable') units[:portable].minimum.as('portable')
) )
.from(units).group(units[:base_id], units[:symbol]) .from(units).group(:base_id, :symbol)
} }
scope :ordered, ->{ scope :ordered, ->{
left_outer_joins(:base).order(ordering) left_outer_joins(:base).order(ordering)
@@ -87,8 +83,8 @@ class Unit < ApplicationRecord
def self.ordering def self.ordering
[arel_table.coalesce(Arel::Table.new(:bases_units)[:symbol], arel_table[:symbol]), [arel_table.coalesce(Arel::Table.new(:bases_units)[:symbol], arel_table[:symbol]),
arel_table[:base_id].not_eq(nil), arel_table[:base_id].not_eq(nil),
arel_table[:multiplier], :multiplier,
arel_table[:symbol]] :symbol]
end end
before_destroy do before_destroy do

View File

@@ -1,23 +1,28 @@
<%= tabular_form_with model: Measurement.new do |form| %> <%= tabular_form_with model: Measurement.new, id: :measurement_form,
<fieldset> class: 'topside-area vflex', html: {onkeydown: 'formProcessKey(event)'} do |form| %>
<table class="items centered"> <table class="items centered">
<tbody id="readouts"> <tbody id="readouts"></tbody>
<tr id="readouts_form">
<td colspan="4">
<%= collection_select :quantity, :id, @quantities, :id, :to_s_with_depth,
{prompt: t('.select_quantity'), disabled: '', selected: ''},
{name: :id, class: 'quantity vexpand',
onchange: "this.form.requestSubmit(new_readout_submit);"} %>
<%= form.submit id: :new_readout_submit, name: nil, value: nil,
formaction: new_readout_path, formmethod: :get, formnovalidate: true,
hidden: true, data: {turbo_stream: true} %>
</td>
</tr>
</tbody>
</table> </table>
</fieldset>
<div class="hflex centered"> <div class="hflex">
<%= form.button -%> <%# TODO: right-click selection %>
<details id="quantity_select" class="hexpand" open
onkeydown="detailsProcessKey(event)">
<summary autofocus>
<!-- TODO: Set content with CSS when span empty to avoid duplication -->
<span data-prompt="<%= t('.select_quantity') %>">
<%= t('.select_quantity') %>
</span>
<%= image_button_tag t(:apply), "update", name: nil, disabled: true,
formaction: new_readout_path, formmethod: :get, formnovalidate: true,
data: {turbo_stream: true} %>
</summary>
<ul><%= quantities_check_boxes %></ul>
</details>
<%= form.button id: :create_measurement_button, disabled: true -%>
</div>
<div class="hflex reverse">
<%= image_link_to t(:cancel), "close-outline", measurements_path, name: :cancel, <%= image_link_to t(:cancel), "close-outline", measurements_path, name: :cancel,
class: 'dangerous', onclick: render_turbo_stream('form_close') %> class: 'dangerous', onclick: render_turbo_stream('form_close') %>
</div> </div>

View File

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

View File

@@ -1,14 +0,0 @@
<%= tag.tr id: dom_id(measurement) do %>
<td><%= l measurement.created_at, format: :short %></td>
<td>
<% measurement.readouts.each do |readout| %>
<span><%= readout.quantity.name %>: <%= readout.value %> <%= readout.unit %></span>
<% end %>
</td>
<% if current_user.at_least(:active) %>
<td class="actions">
<%= image_button_to t('.destroy'), 'delete-outline', measurement_path(measurement),
method: :delete %>
</td>
<% end %>
<% end %>

View File

@@ -1,5 +0,0 @@
<%= turbo_stream.update :flashes %>
<%= turbo_stream.remove :measurement_form %>
<%= turbo_stream.remove :no_items %>
<%= turbo_stream.enable :new_measurement_link %>
<%= turbo_stream.prepend :measurements, @measurement %>

View File

@@ -1,3 +0,0 @@
<%= turbo_stream.update :flashes %>
<%= turbo_stream.remove @measurement %>
<%= turbo_stream.append(:measurements, render_no_items) if @measurements_empty %>

View File

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

View File

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

View File

@@ -1,22 +1,25 @@
<%# TODO: add readout reordering by dragging %> <%# TODO: add readout reordering by dragging %>
<%= tabular_fields_for 'readouts[]', readout do |form| %> <%= tabular_fields_for 'readouts[]', readout do |form| %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout), <%- tag.tr id: dom_id(readout.quantity, :new, :readout) do %>
onkeydown: 'processKey(event)' do %> <td class="actions">
<%# TODO: change to _link_ after giving up displaying relative paths %>
<%= image_button_tag '', 'delete-outline', class: 'dangerous', name: nil,
formaction: discard_readouts_path(readout.quantity),
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>
</td>
<td> <td>
<%= readout.quantity.relative_pathname(@superquantity) %> <%= readout.quantity.relative_pathname(@superquantity) %>
</td> </td>
<td> <td>
<%= form.number_field :value, required: true, autofocus: true, size: 10 %> <%= form.number_field :value, required: true,
size: readout.type_for_attribute(:value).precision / 2,
autofocus: readout_counter == 0 %>
</td> </td>
<td> <td>
<%= form.hidden_field :quantity_id %> <%= form.hidden_field :quantity_id %>
<%= form.collection_select :unit_id, @user_units, :id, <%= form.collection_select :unit_id, @user_units, :id,
->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) } %> ->(u){ sanitize('&emsp;' * (u.base_id ? 1 : 0) + u.symbol) },
</td> {prompt: t('.select_unit'), disabled: '', selected: ''}, required: true %>
<td class="actions">
<%= image_button_tag '', 'delete-outline', class: 'dangerous', name: :discard,
formaction: discard_readouts_path(readout.quantity),
formmethod: :get, formnovalidate: true, data: {turbo_stream: true} %>
</td> </td>
<% end %> <% end %>
<% end %> <% end %>

View File

@@ -1,4 +1,4 @@
<%= turbo_stream.disable :create_measurement_button if @prev_quantities.one? %>
<%= turbo_stream.remove dom_id(@quantity, :new, :readout) %> <%= turbo_stream.remove dom_id(@quantity, :new, :readout) %>
<%= turbo_stream.disable_all 'button[name="discard"]' if @prev_quantities.one? %>
<%= turbo_stream.enable_all "select.quantity option[value='#{@quantity.id}']" %>
<%= render partial: 'form_repath' %> <%= render partial: 'form_repath' %>
<%= turbo_stream.unselect dom_id(@quantity) %>

View File

@@ -1,9 +1,8 @@
<%= render partial: 'form_repath' %>
<%# is .one? proper condition? can @readouts be empty? %>
<%= turbo_stream.enable_all 'button[name="discard"]' if @prev_quantities.one? %>
<% @readouts.each do |r| %> <% @readouts.each do |r| %>
<%= turbo_stream.disable_all "select.quantity option[value='#{r.quantity_id}']" %> <%= turbo_stream.disable dom_id(r.quantity) %>
<% end %> <% end %>
<%= turbo_stream.before :readouts_form do %> <%= render partial: 'form_repath' %>
<%= turbo_stream.append :readouts do %>
<%= render partial: 'form', collection: @readouts, as: :readout %> <%= render partial: 'form', collection: @readouts, as: :readout %>
<% end %> <% end %>
<%= turbo_stream.enable :create_measurement_button if @prev_quantities.empty? %>

View File

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

View File

@@ -0,0 +1,9 @@
<%= labeled_form_for resource, url: user_confirmation_path,
html: {class: 'main-area'} 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 %>

View File

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

View File

@@ -1,5 +1,5 @@
<%= labeled_form_for resource, url: user_password_path, <%= labeled_form_for resource, url: user_password_path,
html: {method: :put, class: 'main-area', data: {turbo: false}} do |f| %> html: {method: :put, class: 'main-area'} do |f| %>
<%= f.hidden_field :reset_password_token %> <%= f.hidden_field :reset_password_token %>

View File

@@ -0,0 +1,8 @@
<%= labeled_form_for resource, url: user_password_path,
html: {class: 'main-area'} do |f| %>
<%= f.email_field :email, required: true, size: 30, autofocus: true,
autocomplete: 'email' %>
<%= f.submit t(:recover_password) %>
<% end %>

View File

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

View File

@@ -0,0 +1,16 @@
<div class="main-area">
<%= labeled_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' %>
<%= f.password_field :password_confirmation, required: true, size: 30,
minlength: @minimum_password_length, autocomplete: 'off' %>
<%= f.submit t(:register) %>
<% end %>
<%= content_tag :p, t(:or), style: 'text-align: center;' %>
<%= image_link_to t(:resend_confirmation), 'email-sync-outline',
new_user_confirmation_path, class: 'centered' %>
</div>

View File

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

View File

@@ -1 +1 @@
OOGMGhfQuV67kqlMecZLcNfgrGS81KPAGmY27GnohtcGSPtiaqL8OZYsVf5IIOaI1K14ZEflln+E2deaIJ5apaq98f+1gJawGbAJeEfLCskJV/03nT8ICpRk+bxT/lzqeCIaUJOLk4708ufC9EpdpJD/jgVSAuI/iNMzzwMbNFvqNmx0Kmgp0mRpHSDGLZZkaP3GW7wdsJEVsNpSPIrkkGL1BvD+nHbmHjuGkn4MMsmm1Yz0M31jkJiDksT0SVeOcxWvOApclxm6VZOAws2l6YKEs/XoE7ye3ssjxdjdwjMzRXV7dwYclNBQGRoERVTozdYiFR4eAGMdlG0RsnUAp+edILH5nvHCIPb3la/dUTOzAQMNY0TqMMVUHGqGuVS/EMCX/w7zrmYN5C+2W8SfugrvTpAL--dUdvsDfbUdVrbmmo--fUwsWUp+DQGPtEF+Zq4ZTw== 3nm9KZNtyLhPgZBVzOOkN2FXHD0uEMuzgb5Sl1MrAMmi6+iEFSzyTHfZFW2mz18VyNz5DDYvTODZqBDQKK+FQh70uEQkmGqaY5XsTOzUFzk56quaPNtZvFEGux1nX2avSbYQBs3HeyYyWyTAFhez5j8tVb6sZD2xZ8twa9KAB42j86NIHT9w/ZMFqZbGbdBoR1Mrqoy9/IWv2QgxMTpGR6JBpTUwauXm6wS/bTt8SCXF57JSVgvdw/BxFzoA3Xj6N5E89LbMfh54W2ruMhybka5E7zXN9z0v4oXt8GiYZFIODEYZwqzEVaUK1WXS5qb5OrDJFAzs29Uf/gDrIDx71Lot+jejCS+xFfI9454EnHcVH66wKuwF6ylKupJDffM0hQHplcEfVSq5UiDfbPXm46Vr0g1A--2RrmuzCBuHvYpPNA--ugbuRe7ivfDqeUCt6ahciA==

View File

@@ -58,7 +58,4 @@ Rails.application.configure do
# config.action_view.annotate_rendered_view_with_filenames = true # config.action_view.annotate_rendered_view_with_filenames = true
config.log_level = :info config.log_level = :info
# Allow the default integration test host.
config.hosts << "www.example.com"
end end

View File

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

View File

@@ -4,15 +4,15 @@ en:
devise: devise:
confirmations: confirmations:
confirmed: "Your email address has been successfully confirmed." confirmed: "Your email address has been successfully confirmed."
send_paranoid_instructions: > send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes."
If your email address is in our database, a message with instructions on how send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes."
to confirm your email address has been sent to you.
failure: failure:
already_authenticated: "You are already signed in." already_authenticated: "You are already signed in."
inactive: "Your account is not activated yet." inactive: "Your account is not activated yet."
invalid: "Invalid <b>%{authentication_keys}</b> or <b>password</b>." invalid: "Invalid %{authentication_keys} or password."
locked: "Your account is locked." locked: "Your account is locked."
last_attempt: "You have one more attempt before your account is locked." last_attempt: "You have one more attempt before your account is locked."
not_found_in_database: "Invalid %{authentication_keys} or password."
timeout: "Your session expired. Please sign in again to continue." timeout: "Your session expired. Please sign in again to continue."
unauthenticated: "You need to sign in or sign up before continuing." unauthenticated: "You need to sign in or sign up before continuing."
unconfirmed: "You have to confirm your email address before continuing." unconfirmed: "You have to confirm your email address before continuing."
@@ -32,9 +32,8 @@ en:
success: "Successfully authenticated from %{kind} account." success: "Successfully authenticated from %{kind} account."
passwords: passwords:
no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided." no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided."
send_paranoid_instructions: > send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes."
If your email address is in our database, the password recovery link has been send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes."
sent to you.
updated: "Your password has been changed successfully. You are now signed in." updated: "Your password has been changed successfully. You are now signed in."
updated_not_active: "Your password has been changed successfully." updated_not_active: "Your password has been changed successfully."
registrations: registrations:
@@ -51,6 +50,7 @@ en:
signed_out: "Signed out successfully." signed_out: "Signed out successfully."
already_signed_out: "Signed out successfully." already_signed_out: "Signed out successfully."
unlocks: unlocks:
send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes."
send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes." send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes."
unlocked: "Your account has been unlocked successfully. Please sign in to continue." unlocked: "Your account has been unlocked successfully. Please sign in to continue."
errors: errors:

View File

@@ -85,19 +85,12 @@ en:
navigation: Measurements navigation: Measurements
no_items: There are no measurements taken. You can Add some now. no_items: There are no measurements taken. You can Add some now.
form: form:
select_quantity: select the measured quantity... select_quantity: select the measured quantities...
index: index:
new_measurement: Add measurement new_measurement: Add measurement
create:
success: Measurement saved.
destroy:
success: Measurement deleted.
measurement:
destroy: Delete
readouts: readouts:
form: form:
select_unit: ... select_unit: ...
new_children: Children
quantities: quantities:
navigation: Quantities navigation: Quantities
no_items: There are no configured quantities. You can Add some or Import from defaults. no_items: There are no configured quantities. You can Add some or Import from defaults.
@@ -157,7 +150,7 @@ en:
edit: edit:
password_html: 'New password:%{password_length_hint_html}' password_html: 'New password:%{password_length_hint_html}'
update_password: Update password update_password: Update password
profiles: registrations:
new: new:
password_html: 'Password:%{password_length_hint_html}' password_html: 'Password:%{password_length_hint_html}'
password_confirmation: 'Retype password:' password_confirmation: 'Retype password:'
@@ -196,6 +189,7 @@ en:
cancel: Cancel cancel: Cancel
delete: Delete delete: Delete
:no: 'no' :no: 'no'
or: or
register: Register register: Register
sign_in: Sign in sign_in: Sign in
recover_password: Recover password recover_password: Recover password

View File

@@ -4,7 +4,6 @@ Rails.application.routes.draw do
resources :measurements resources :measurements
resources :readouts, only: [:new] do resources :readouts, only: [:new] do
collection {get 'new/:id/discard', action: :discard, as: :discard} collection {get 'new/:id/discard', action: :discard, as: :discard}
end end
@@ -28,9 +27,8 @@ Rails.application.routes.draw do
# https://github.com/heartcombo/devise/issues/5786 # https://github.com/heartcombo/devise/issues/5786
connection = ActiveRecord::Base.connection connection = ActiveRecord::Base.connection
if connection.schema_version && connection.table_exists?(:users) if connection.schema_version && connection.table_exists?(:users)
# NOTE: change helper prefix from *_registration to *_profile once possible
devise_for :users, path: '', path_names: {registration: 'profile'}, devise_for :users, path: '', path_names: {registration: 'profile'},
controllers: {registrations: 'user/profiles'} controllers: {registrations: :registrations}
end end
resources :users, only: [:index, :show, :update] do resources :users, only: [:index, :show, :update] do
@@ -39,8 +37,10 @@ Rails.application.routes.draw do
end end
unauthenticated do unauthenticated do
as :user do
root to: redirect('/sign_in') root to: redirect('/sign_in')
end end
end
root to: redirect('/units'), as: :user_root root to: redirect('/units'), as: :user_root
direct(:source_code) { 'https://gitea.michalczyk.pro/fixin.me/fixin.me' } direct(:source_code) { 'https://gitea.michalczyk.pro/fixin.me/fixin.me' }

View File

@@ -1,50 +0,0 @@
namespace :test do
desc "Run Rails tests against all supported database adapters (SQLite, MySQL)"
task :all_adapters do
# DATABASE_URL overrides the adapter from database.yml at runtime.
# MySQL requires the mysql2 gem: bundle install --with mysql
adapters = {
"SQLite" => {
"DATABASE_URL" => "sqlite3:db/test.sqlite3"
},
"MySQL" => {
"DATABASE_URL" => format(
"mysql2://%s:%s@%s/%s",
ENV.fetch("DATABASE_USERNAME", "root"),
ENV.fetch("DATABASE_PASSWORD", ""),
ENV.fetch("DATABASE_HOST", "127.0.0.1"),
ENV.fetch("DATABASE_NAME", "fixin_test")
)
}
}
failed = []
adapters.each do |name, extra_env|
puts "\n#{"=" * 60}"
puts " Running tests with #{name}"
puts "=" * 60
env = ENV.to_h.merge("RAILS_ENV" => "test").merge(extra_env)
# Reset test database; db:drop may fail on first run — that's fine
system(env, "bin/rails db:drop")
unless system(env, "bin/rails db:create db:schema:load")
failed << "#{name} (database setup)"
next
end
failed << name unless system(env, "bin/rails test")
end
puts "\n#{"=" * 60}"
if failed.any?
puts " FAILED: #{failed.join(", ")}"
puts "=" * 60
exit 1
else
puts " All adapters passed!"
puts "=" * 60
end
end
end

View File

@@ -1,7 +1,6 @@
require "test_helper" require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
include ActionView::Helpers::SanitizeHelper
include ActionView::Helpers::UrlHelper include ActionView::Helpers::UrlHelper
# NOTE: geckodriver installed with Firefox, ignore incompatibility warning # NOTE: geckodriver installed with Firefox, ignore incompatibility warning
@@ -33,8 +32,7 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
# Allow skipping interpolations when translating for testing purposes # Allow skipping interpolations when translating for testing purposes
INTERPOLATION_PATTERNS = Regexp.union(I18n.config.interpolation_patterns) INTERPOLATION_PATTERNS = Regexp.union(I18n.config.interpolation_patterns)
def translate(key, **options) def translate(key, **options)
translation = options.empty? ? super.split(INTERPOLATION_PATTERNS, 2).first : super options.empty? ? super.split(INTERPOLATION_PATTERNS, 2).first : super
sanitize(translation, tags: [])
end end
alias :t :translate alias :t :translate

View File

@@ -1,12 +1,8 @@
require "test_helper" require "test_helper"
class Default::UnitsControllerTest < ActionDispatch::IntegrationTest class Default::UnitsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:alice)
end
test "should get index" do test "should get index" do
get default_units_url get units_defaults_index_url
assert_response :success assert_response :success
end end
end end

View File

@@ -2,9 +2,7 @@ require "test_helper"
class UsersControllerTest < ActionDispatch::IntegrationTest class UsersControllerTest < ActionDispatch::IntegrationTest
setup do setup do
@admin = users(:admin) @user = users(:one)
@user = users(:alice)
sign_in @admin
end end
test "should get index" do test "should get index" do
@@ -12,25 +10,39 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_response :success assert_response :success
end end
test "should get new" do
get new_user_url
assert_response :success
end
test "should create user" do
assert_difference("User.count") do
post users_url, params: { user: { email: @user.email, status: @user.status } }
end
assert_redirected_to user_url(User.last)
end
test "should show user" do test "should show user" do
get user_url(@user) get user_url(@user)
assert_response :success assert_response :success
end end
test "should get edit" do
get edit_user_url(@user)
assert_response :success
end
test "should update user" do test "should update user" do
patch user_url(@user), params: { user: { status: :restricted } }, as: :turbo_stream patch user_url(@user), params: { user: { email: @user.email, status: @user.status } }
assert_equal "restricted", @user.reload.status assert_redirected_to user_url(@user)
end end
test "should not update self" do test "should destroy user" do
patch user_url(@admin), params: { user: { status: :active } }, as: :turbo_stream, assert_difference("User.count", -1) do
headers: { "HTTP_REFERER" => users_url } delete user_url(@user)
assert_response :redirect
end end
test "should forbid non-admin" do assert_redirected_to users_url
sign_in @user
get users_url
assert_response :forbidden
end end
end end

View File

@@ -5,8 +5,8 @@ class UsersTest < ApplicationSystemTestCase
@admin = users(:admin) @admin = users(:admin)
end end
test 'sign in' do test "sign in" do
visit root_url visit new_user_session_path
assert find_link(href: new_user_session_path)[:disabled] assert find_link(href: new_user_session_path)[:disabled]
sign_in sign_in
@@ -14,23 +14,16 @@ class UsersTest < ApplicationSystemTestCase
assert_text t('devise.sessions.signed_in') assert_text t('devise.sessions.signed_in')
end end
test 'sign in fails with invalid credentials' do test 'sign in fails with invalid password' do
label = User.human_attribute_name(:email) sign_in password: random_password
# Both: valid and invalid emails should give the same (paranoid) error message.
email = [users.sample.email, random_email].sample
visit root_url
fill_in label, with: email
fill_in User.human_attribute_name(:password), with: random_password
click_on t(:sign_in)
assert_current_path new_user_session_path assert_current_path new_user_session_path
assert_text t('devise.failure.invalid', authentication_keys: label.downcase_first) assert_text t('devise.failure.not_found_in_database',
authentication_keys: User.human_attribute_name(:email))
assert find_link(href: new_user_session_path)[:disabled] assert find_link(href: new_user_session_path)[:disabled]
assert has_field?(label, with: email) assert_not_empty find_field(User.human_attribute_name(:email)).value
end end
test 'sign out' do test "sign out" do
sign_in sign_in
visit root_url visit root_url
click_on t("layouts.application.sign_out") click_on t("layouts.application.sign_out")
@@ -38,106 +31,79 @@ class UsersTest < ApplicationSystemTestCase
assert_text t("devise.sessions.signed_out") assert_text t("devise.sessions.signed_out")
end end
test 'recover password' do test "recover password" do
label = User.human_attribute_name(:email) visit new_user_session_url
email = users.select(&:confirmed?).sample.email click_on t(:recover_password)
visit root_url
fill_in label, with: email
# Form validations should allow empty password.
assert has_field?(User.human_attribute_name(:password), with: nil)
fill_in User.human_attribute_name(:email),
with: users.select(&:confirmed?).sample.email
assert_emails 1 do assert_emails 1 do
click_on t(:recover_password) click_on t(:recover_password)
# Wait until redirected to make sure async request has been processed
assert_current_path new_user_session_path assert_current_path new_user_session_path
# Wait for flash message to make sure async request has been processed.
assert_text t("devise.passwords.send_paranoid_instructions")
end end
assert has_field?(label, with: email) assert_text t("devise.passwords.send_instructions")
with_last_email do |mail| with_last_email do |mail|
visit Capybara.string(mail.body.to_s).find_link("Change my password")[:href] visit Capybara.string(mail.body.to_s).find_link("Change my password")[:href]
assert_current_path edit_user_password_path, ignore_query: true
# Make sure flash message is not displayed twice.
assert_no_text t("devise.passwords.send_paranoid_instructions")
end end
new_password = random_password new_password = random_password
fill_in t("users.passwords.edit.password_html"), with: new_password fill_in t("users.passwords.edit.password_html"), with: new_password
fill_in t("helpers.label.user.password_confirmation"), with: new_password fill_in t("helpers.label.user.password_confirmation"), with: new_password
assert_emails 1 do assert_emails 1 do
click_on t("users.passwords.edit.update_password") click_on t("users.passwords.edit.update_password")
# Wait until redirected to make sure async request has been processed
assert_current_path units_path assert_current_path units_path
end
assert_text t("devise.passwords.updated") assert_text t("devise.passwords.updated")
end end
end
test 'recover password for nonexistent user' do test "register" do
label = User.human_attribute_name(:email) visit new_user_session_url
email = random_email
visit root_url
fill_in label, with: email
assert_no_emails do
click_on t(:recover_password)
assert_current_path new_user_session_path
assert_text t("devise.passwords.send_paranoid_instructions")
end
end
test 'register' do
visit root_url
click_on t(:register) click_on t(:register)
assert find_link(href: new_user_registration_path)[:disabled]
fill_in User.human_attribute_name(:email), with: random_email fill_in User.human_attribute_name(:email), with: random_email
password = random_password password = random_password
fill_in User.human_attribute_name(:password), with: password fill_in User.human_attribute_name(:password), with: password
fill_in t("users.profiles.new.password_confirmation"), with: password fill_in t("users.registrations.new.password_confirmation"), with: password
assert_difference ->{User.count}, 1 do assert_difference ->{User.count}, 1 do
assert_emails 1 do assert_emails 1 do
click_on t(:register) click_on t(:register)
# Wait until redirected to make sure async request has been processed
assert_current_path new_user_session_path assert_current_path new_user_session_path
end
end
assert_text t("devise.registrations.signed_up_but_unconfirmed") assert_text t("devise.registrations.signed_up_but_unconfirmed")
end
end
assert_changes ->{ User.last.confirmed? }, from: false, to: true do
with_last_email do |mail| with_last_email do |mail|
visit Capybara.string(mail.body.to_s).find_link("Confirm my account")[:href] visit Capybara.string(mail.body.to_s).find_link("Confirm my account")[:href]
end
assert_current_path new_user_session_path assert_current_path new_user_session_path
assert_text t("devise.confirmations.confirmed") assert_text t("devise.confirmations.confirmed")
end assert User.last.confirmed?
end
end end
test 'resend confirmation' do test "resend confirmation" do
label = User.human_attribute_name(:email) visit new_user_session_url
user = users.reject(&:confirmed?).sample
visit root_url
click_on t(:register) click_on t(:register)
fill_in label, with: user.email click_on t(:resend_confirmation)
assert has_field?(User.human_attribute_name(:password), with: nil)
fill_in User.human_attribute_name(:email),
with: users.reject(&:confirmed?).sample.email
assert_emails 1 do assert_emails 1 do
click_on t(:resend_confirmation) click_on t(:resend_confirmation)
assert_current_path new_user_registration_path # Wait until redirected to make sure async request has been processed
assert_text t("devise.confirmations.send_paranoid_instructions") assert_current_path new_user_session_path
end end
assert has_field?(label, with: user.email) assert_current_path new_user_session_path
assert_text t("devise.confirmations.send_instructions")
assert_changes ->{ user.reload.confirmed? }, from: false, to: true do
with_last_email do |mail| with_last_email do |mail|
visit Capybara.string(mail.body.to_s).find_link("Confirm my account")[:href] visit Capybara.string(mail.body.to_s).find_link("Confirm my account")[:href]
assert_current_path new_user_session_path
assert_no_text t("devise.confirmations.send_paranoid_instructions")
assert_text t("devise.confirmations.confirmed")
end
end end
end end
test 'show profile' do test "show profile" do
sign_in user: users.select(&:admin?).select(&:confirmed?).sample sign_in user: users.select(&:admin?).select(&:confirmed?).sample
click_on t("users.navigation") click_on t("users.navigation")
within all('tr').drop(1).sample do |tr| within all('tr').drop(1).sample do |tr|
@@ -147,7 +113,7 @@ class UsersTest < ApplicationSystemTestCase
end end
end end
test 'disguise' do test "disguise" do
user = users.select(&:admin?).select(&:confirmed?).sample user = users.select(&:admin?).select(&:confirmed?).sample
sign_in user: user sign_in user: user
@@ -163,7 +129,7 @@ class UsersTest < ApplicationSystemTestCase
assert_link user.email assert_link user.email
end end
test 'disguise fails for admin when disallowed' do test "disguise fails for admin when disallowed" do
user = users.select(&:admin?).select(&:confirmed?).sample user = users.select(&:admin?).select(&:confirmed?).sample
sign_in user: user sign_in user: user
@@ -176,13 +142,13 @@ class UsersTest < ApplicationSystemTestCase
assert_title 'The change you wanted was rejected (422)' assert_title 'The change you wanted was rejected (422)'
end end
test 'disguise forbidden for non admin' do test "disguise forbidden for non admin" do
sign_in user: users.reject(&:admin?).select(&:confirmed?).sample sign_in user: users.reject(&:admin?).select(&:confirmed?).sample
visit disguise_user_path(User.all.sample) visit disguise_user_path(User.all.sample)
assert_title 'Access is forbidden to this page (403)' assert_title 'Access is forbidden to this page (403)'
end end
test 'delete profile' do test "delete profile" do
user = sign_in user: users.reject(&:admin?).select(&:confirmed?).sample user = sign_in user: users.reject(&:admin?).select(&:confirmed?).sample
# TODO: remove condition after root_url changed to different path than # TODO: remove condition after root_url changed to different path than
# profile in routes.rb # profile in routes.rb
@@ -190,13 +156,13 @@ class UsersTest < ApplicationSystemTestCase
first(:link_or_button, user.email).click first(:link_or_button, user.email).click
end end
assert_difference ->{ User.count }, -1 do assert_difference ->{ User.count }, -1 do
accept_confirm { click_on t("users.profiles.edit.delete") } accept_confirm { click_on t("users.registrations.edit.delete") }
assert_current_path new_user_session_path assert_current_path new_user_session_path
end end
assert_text t("devise.registrations.destroyed") assert_text t("devise.registrations.destroyed")
end end
test 'sole admin cannot delete profile' do test "sole admin cannot delete profile" do
sign_in user: users(:admin) sign_in user: users(:admin)
unless has_current_path?(edit_user_registration_path) unless has_current_path?(edit_user_registration_path)
first(:link_or_button, users(:admin).email).click first(:link_or_button, users(:admin).email).click
@@ -204,17 +170,17 @@ class UsersTest < ApplicationSystemTestCase
assert find(:button, t("users.registrations.edit.delete"))[:disabled] assert find(:button, t("users.registrations.edit.delete"))[:disabled]
end end
test 'index forbidden for non admin' do test "index forbidden for non admin" do
sign_in user: users.reject(&:admin?).select(&:confirmed?).sample sign_in user: users.reject(&:admin?).select(&:confirmed?).sample
visit users_path visit users_path
assert_title "Access is forbidden to this page (403)" assert_title "Access is forbidden to this page (403)"
end end
test 'update profile' do test "update profile" do
# TODO # TODO
end end
test 'update status' do test "update status" do
sign_in user: users.select(&:admin?).select(&:confirmed?).sample sign_in user: users.select(&:admin?).select(&:confirmed?).sample
visit users_path visit users_path
@@ -229,7 +195,7 @@ class UsersTest < ApplicationSystemTestCase
assert_current_path users_path assert_current_path users_path
end end
test 'update status fails for admin when disallowed' do test "update status fails for admin when disallowed" do
sign_in user: users.select(&:admin?).select(&:confirmed?).sample sign_in user: users.select(&:admin?).select(&:confirmed?).sample
visit users_path visit users_path
@@ -242,7 +208,7 @@ class UsersTest < ApplicationSystemTestCase
assert_title 'The change you wanted was rejected (422)' assert_title 'The change you wanted was rejected (422)'
end end
test 'update status forbidden for non admin' do test "update status forbidden for non admin" do
sign_in user: users.reject(&:admin?).select(&:confirmed?).sample sign_in user: users.reject(&:admin?).select(&:confirmed?).sample
visit units_path visit units_path
inject_button_to find('body'), "update status", user_path(User.all.sample), method: :patch, inject_button_to find('body'), "update status", user_path(User.all.sample), method: :patch,

View File

@@ -2,10 +2,6 @@ ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment" require_relative "../config/environment"
require "rails/test_help" require "rails/test_help"
class ActionDispatch::IntegrationTest
include Devise::Test::IntegrationHelpers
end
class ActiveSupport::TestCase class ActiveSupport::TestCase
# Run tests in parallel with specified workers # Run tests in parallel with specified workers
parallelize(workers: :number_of_processors) parallelize(workers: :number_of_processors)