Compare commits

..

2 Commits

Author SHA1 Message Date
0a56bee826 Fix seed admin password too short for Devise validation
Generate a random 12-character alphanumeric password instead of the
hardcoded 'admin' (5 chars) which fails Devise's minimum length
requirement of 8 characters. The generated password is printed to
stdout so the operator knows what to use.

Fixes #71
2026-03-10 17:04:07 +00:00
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
46 changed files with 407 additions and 676 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,26 +61,18 @@ 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],
numbered.cast(numbered[:child_number], 'BINARY').as('path') numbered.cast(numbered[:child_number], 'BINARY').as('path')
).where(numbered[root && include_root ? :id : :parent_id].eq(root)), ).where(numbered[root && include_root ? :id : :parent_id].eq(root)),
numbered.project( numbered.project(
numbered[Arel.star], numbered[Arel.star],
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"> </table>
<td colspan="4">
<%= collection_select :quantity, :id, @quantities, :id, :to_s_with_depth, <div class="hflex">
{prompt: t('.select_quantity'), disabled: '', selected: ''}, <%# TODO: right-click selection %>
{name: :id, class: 'quantity vexpand', <details id="quantity_select" class="hexpand" open
onchange: "this.form.requestSubmit(new_readout_submit);"} %> onkeydown="detailsProcessKey(event)">
<%= form.submit id: :new_readout_submit, name: nil, value: nil, <summary autofocus>
formaction: new_readout_path, formmethod: :get, formnovalidate: true, <!-- TODO: Set content with CSS when span empty to avoid duplication -->
hidden: true, data: {turbo_stream: true} %> <span data-prompt="<%= t('.select_quantity') %>">
</td> <%= t('.select_quantity') %>
</tr> </span>
</tbody> <%= image_button_tag t(:apply), "update", name: nil, disabled: true,
</table> formaction: new_readout_path, formmethod: :get, formnovalidate: true,
</fieldset> data: {turbo_stream: true} %>
<div class="hflex centered"> </summary>
<%= form.button -%> <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,
autocomplete: 'email' %>
<%= f.password_field :password, required: true, size: 30,
minlength: @minimum_password_length, autocomplete: 'current-password' %>
<%= f.email_field :email, required: true, size: 30, autofocus: true, <% if devise_mapping.rememberable? %>
autocomplete: 'email' %> <%= f.check_box :remember_me %>
<%= f.password_field :password, required: true, size: 30, <% end %>
autocomplete: 'current-password' %>
<% if devise_mapping.rememberable? %> <%= f.submit t(:sign_in) %>
<%= f.check_box :remember_me %>
<% end %> <% end %>
<%# /sign_in as HTML; /password as TURBO_STREAM %> <%= content_tag :p, t(:or), style: 'text-align: center;' %>
<%= f.submit t(:sign_in), data: {turbo: false} %> <%= image_link_to t(:recover_password), 'lock-reset', new_user_password_path,
class: 'centered' %>
<%= image_button_tag t(:recover_password), 'lock-reset', class: 'auxiliary', </div>
formaction: user_password_path, formnovalidate: true,
data: {validate: f.field_id(:email)} %>
<% end %>

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,7 +37,9 @@ Rails.application.routes.draw do
end end
unauthenticated do unauthenticated do
root to: redirect('/sign_in') as :user do
root to: redirect('/sign_in')
end
end end
root to: redirect('/units'), as: :user_root root to: redirect('/units'), as: :user_root

View File

@@ -18,9 +18,10 @@
User.transaction do User.transaction do
break if User.find_by status: :admin break if User.find_by status: :admin
User.create! email: Rails.configuration.admin, password: 'admin', status: :admin do |user| password = SecureRandom.alphanumeric(12)
User.create! email: Rails.configuration.admin, password: password, status: :admin do |user|
user.skip_confirmation! user.skip_confirmation!
print "Creating #{user.status} account '#{user.email}' with password '#{user.password}'..." print "Creating #{user.status} account '#{user.email}' with password '#{password}'..."
end end
puts "done." puts "done."

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
assert_text t("devise.passwords.updated")
end end
assert_text t("devise.passwords.updated")
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
assert_text t("devise.registrations.signed_up_but_unconfirmed")
end end
end end
assert_text t("devise.registrations.signed_up_but_unconfirmed")
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]
assert_current_path new_user_session_path
assert_text t("devise.confirmations.confirmed")
end
end end
assert_current_path new_user_session_path
assert_text t("devise.confirmations.confirmed")
assert User.last.confirmed?
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)