Compare commits

..

2 Commits

Author SHA1 Message Date
3fe43d1fc0 Fix quantity ordered scope for SQLite: use pathname column instead of recursive CTE
SQLite's Arel visitor wraps CTE branches in extra parentheses, making
the UNION ALL inside recursive CTEs invalid. Also SQLite lacks LPAD()
and CAST(... AS BINARY). Fix by using the existing pathname column for
ordering on SQLite, which already encodes the hierarchical path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 18:41:03 +00:00
9b18784caf Implement measurements create/destroy and index listing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 18:24:27 +00:00
34 changed files with 265 additions and 618 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,13 +73,45 @@ 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], [name=cancel],
.auxiliary { .auxiliary {
@@ -99,7 +122,7 @@ textarea {
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 +130,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 {
@@ -128,80 +149,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 +189,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 +213,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 +247,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 +290,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 +306,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,20 +320,30 @@ 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;
} }
@@ -363,7 +365,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);
} }
@@ -390,10 +392,10 @@ header {
} }
/* 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 +418,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 +448,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 +467,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 +478,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 +517,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 +529,25 @@ 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 {
border-color: var(--color-border-gray);
color: var(--color-table-gray);
}
form table.items { form table.items {
border: none; border: none;
} }
@@ -557,9 +559,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 +574,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 +629,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 +645,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 +670,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

@@ -9,7 +9,6 @@ class ApplicationController < ActionController::Base
helper_method :current_user_disguised? helper_method :current_user_disguised?
helper_method :current_tab helper_method :current_tab
before_action :redirect_to_setup_if_needed
before_action :authenticate_user! before_action :authenticate_user!
class AccessForbidden < StandardError; end class AccessForbidden < StandardError; end
@@ -56,16 +55,6 @@ class ApplicationController < ActionController::Base
private private
# Redirect to the web setup wizard when the application has not yet been
# initialised (i.e. no admin account exists in the database).
def redirect_to_setup_if_needed
return if User.exists?(status: :admin)
redirect_to new_setup_path
rescue ActiveRecord::StatementInvalid
# Tables may not exist yet (migrations not run). Fall through and let the
# normal request handling surface a meaningful error.
end
def render_no_content(record) def render_no_content(record)
helpers.render_errors(record) helpers.render_errors(record)
render html: nil, layout: true render html: nil, layout: true

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,59 +0,0 @@
# Handles the one-time web-based installation wizard.
#
# The wizard is only accessible when no admin account exists yet. Once an
# admin has been created the controller redirects every request to the root
# path, so it can never be used to overwrite an existing installation.
class SetupController < ActionController::Base
# Use the full application layout (header, flash, etc.) so the page looks
# consistent with the rest of the site.
layout "application"
before_action :redirect_if_installed
def new
end
def create
email = params[:admin_email].to_s.strip
password = params[:admin_password].to_s
confirm = params[:admin_password_confirmation].to_s
errors = []
errors << t(".email_blank") if email.blank?
errors << t(".password_blank") if password.blank?
errors << t(".password_mismatch") if password != confirm
if errors.any?
flash.now[:alert] = errors.join(" ")
return render :new, status: :unprocessable_entity
end
user = User.new(email: email, password: password, status: :admin)
user.skip_confirmation!
unless user.save
flash.now[:alert] = user.errors.full_messages.join(" ")
return render :new, status: :unprocessable_entity
end
# Persist runtime settings chosen during setup.
Setting.set("skip_email_confirmation",
params[:skip_email_confirmation] == "1")
# Optionally seed the built-in default units.
if params[:seed_units] == "1"
load Rails.root.join("db/seeds/units.rb")
end
redirect_to new_user_session_path, notice: t(".success")
end
private
def redirect_if_installed
redirect_to root_path if User.exists?(status: :admin)
rescue ActiveRecord::StatementInvalid
# Tables are not yet migrated — stay on the setup page so the user sees a
# meaningful error rather than a crash.
end
end

View File

@@ -1,24 +1,11 @@
class User::ProfilesController < Devise::RegistrationsController class User::ProfilesController < Devise::RegistrationsController
def destroy def destroy
if current_user.sole_admin? # TODO: Disallow/disable deletion for last admin account; update :edit view
redirect_back fallback_location: edit_user_registration_path,
alert: t(".sole_admin")
return
end
super super
end end
protected protected
def build_resource(hash = {})
super
# Skip the email confirmation step when the admin has enabled this option
# via the web setup wizard (stored as the "skip_email_confirmation" Setting).
# The account becomes active immediately so the user can sign in right after
# registering.
resource.skip_confirmation! if Setting.get("skip_email_confirmation") == "true"
end
def update_resource(resource, params) def update_resource(resource, params)
# Based on update_with_password() # Based on update_with_password()
if params[:password].blank? if params[:password].blank?

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)
@@ -114,12 +108,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
@@ -153,8 +143,7 @@ module ApplicationHelper
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)
@@ -223,8 +212,9 @@ 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)) + # TODO: change button text to svg to make it aligned vertically
svg_tag('pictograms/close-outline', {onclick: "this.parentElement.remove()"}) tag.div(sanitize(message)) + tag.button(sanitize("&times;"), tabindex: -1,
onclick: "this.parentElement.remove();")
end end
end end
end.join.html_safe end.join.html_safe

View File

@@ -1,20 +0,0 @@
# Key-value store for runtime application settings that are configured through
# the web setup wizard (or updated by an administrator) rather than hard-coded
# in application.rb.
#
# Known keys:
# skip_email_confirmation "true"/"false", mirrors the homonymous option
# that was previously in application.rb.
class Setting < ApplicationRecord
validates :key, presence: true, uniqueness: true
# Return the string value stored for +key+, or +default+ when absent.
def self.get(key, default: nil)
find_by(key: key)&.value || default
end
# Persist +value+ for +key+, creating the record if it does not yet exist.
def self.set(key, value)
find_or_initialize_by(key: key).update!(value: value.to_s)
end
end

View File

@@ -12,8 +12,8 @@ class Unit < ApplicationRecord
errors.add(:base, :multilevel_nesting) if base.base_id? errors.add(:base, :multilevel_nesting) if base.base_id?
end end
validates :symbol, presence: true, uniqueness: {scope: :user_id}, validates :symbol, presence: true, uniqueness: {scope: :user_id},
length: {maximum: type_for_attribute(:symbol).limit || Float::INFINITY} length: {maximum: type_for_attribute(:symbol).limit}
validates :description, length: {maximum: type_for_attribute(:description).limit || Float::INFINITY} validates :description, length: {maximum: type_for_attribute(:description).limit}
validates :multiplier, numericality: {equal_to: 1}, unless: :base validates :multiplier, numericality: {equal_to: 1}, unless: :base
validates :multiplier, numericality: {greater_than: 0, precision: true, scale: true}, if: :base validates :multiplier, numericality: {greater_than: 0, precision: true, scale: true}, if: :base
@@ -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

@@ -29,11 +29,4 @@ class User < ApplicationRecord
def at_least(status) def at_least(status)
User.statuses[self.status] >= User.statuses[status] User.statuses[self.status] >= User.statuses[status]
end end
# Returns true when this user is the only admin account in the system.
# Used to block actions that would leave the application without an admin
# (account deletion, status demotion).
def sole_admin?
admin? && !User.admin.where.not(id: id).exists?
end
end end

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,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,39 +0,0 @@
<%= form_with url: setup_path, method: :post, class: "labeled-form main-area" do %>
<h3 style="grid-column: 1 / -1; text-align: left; margin: 0;">
<%= t(".admin_account") %>
</h3>
<label for="admin_email"><%= t(".admin_email") %></label>
<%= email_field_tag :admin_email, params[:admin_email],
id: "admin_email", required: true, size: 30, autofocus: true,
autocomplete: "email" %>
<label for="admin_password"><%= t(".admin_password") %></label>
<%= password_field_tag :admin_password, nil,
id: "admin_password", required: true, size: 30,
autocomplete: "new-password" %>
<label for="admin_password_confirmation"><%= t(".admin_password_confirmation") %></label>
<%= password_field_tag :admin_password_confirmation, nil,
id: "admin_password_confirmation", required: true, size: 30,
autocomplete: "off" %>
<h3 style="grid-column: 1 / -1; text-align: left; margin: 0.5em 0 0 0;">
<%= t(".options") %>
</h3>
<label for="skip_email_confirmation" style="grid-column: 1 / 3; text-align: left;">
<%= check_box_tag :skip_email_confirmation, "1",
params[:skip_email_confirmation] == "1",
id: "skip_email_confirmation" %>
<%= t(".skip_email_confirmation") %>
</label>
<label for="seed_units" style="grid-column: 1 / 3; text-align: left;">
<%= check_box_tag :seed_units, "1", true, id: "seed_units" %>
<%= t(".seed_units") %>
</label>
<%= submit_tag t(".submit") %>
<% end %>

View File

@@ -4,8 +4,9 @@
<% end %> <% end %>
<div class="rightside-area buttongrid"> <div class="rightside-area buttongrid">
<%= image_button_to_if !current_user.sole_admin?, t('.delete'), 'account-remove-outline', <%#= TODO: Disallow/disable deletion for last admin account, image_button_to_if %>
user_registration_path, form_class: 'tools-area', method: :delete, data: {turbo: false}, <%= image_button_to t('.delete'), 'account-remove-outline', user_registration_path,
form_class: 'tools-area', method: :delete, data: {turbo: false},
onclick: {confirm: t('.confirm_delete')} %> onclick: {confirm: t('.confirm_delete')} %>
</div> </div>

View File

@@ -54,9 +54,5 @@ module FixinMe
# Sender address of account registration-related messages # Sender address of account registration-related messages
Devise.mailer_sender = 'noreply@localhost' Devise.mailer_sender = 'noreply@localhost'
# Whether to skip e-mail confirmation for new registrations is configured
# through the web setup wizard and stored in the database (Setting model),
# so it does not need to be set here.
end end
end 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

@@ -85,7 +85,7 @@ 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: create:
@@ -97,7 +97,6 @@ en:
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.
@@ -169,27 +168,7 @@ en:
New password: New password:
<br><em>leave blank to keep unchanged</em> <br><em>leave blank to keep unchanged</em>
%{password_length_hint_html} %{password_length_hint_html}
registrations:
destroy:
sole_admin: You cannot delete the only admin account.
actions: Actions actions: Actions
setup:
new:
admin_account: Admin account
admin_email: 'E-mail:'
admin_password: 'Password:'
admin_password_confirmation: 'Retype password:'
options: Options
skip_email_confirmation: Skip e-mail confirmation for new registrations
seed_units: Seed built-in default units
submit: Set up
create:
email_blank: E-mail cannot be blank.
password_blank: Password cannot be blank.
password_mismatch: Passwords do not match.
success: >
Installation complete. You can now sign in with the admin account you
just created.
add: Add add: Add
apply: Apply apply: Apply
back: Back back: Back

View File

@@ -1,10 +1,6 @@
Rails.application.routes.draw do Rails.application.routes.draw do
# Web-based installation wizard — only reachable when no admin exists yet.
resource :setup, only: [:new, :create], controller: :setup
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

View File

@@ -1,12 +0,0 @@
class CreateSettings < ActiveRecord::Migration[7.2]
def change
create_table :settings do |t|
t.string :key, null: false
t.string :value
t.timestamps
end
add_index :settings, :key, unique: true
end
end

View File

@@ -3,17 +3,6 @@
# bin/rails db:seed # bin/rails db:seed
# command (or created alongside the database with db:setup). # command (or created alongside the database with db:setup).
# Seeding process should be idempotent. # Seeding process should be idempotent.
#
# Admin account setup
# -------------------
# The preferred way to create the first admin account is through the web setup
# wizard, which is shown automatically on the first visit when no admin exists.
# The wizard also lets you configure runtime options (e.g. skip e-mail
# confirmation) and seed the default units without using the command line.
#
# The block below provides an alternative CLI path for headless / automated
# deployments. It is skipped when an admin account already exists (e.g. after
# the web wizard has run).
User.transaction do User.transaction do
break if User.find_by status: :admin break if User.find_by status: :admin

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

@@ -1,18 +0,0 @@
require "test_helper"
class RegistrationsControllerTest < ActionDispatch::IntegrationTest
test "sole admin cannot delete account" do
sign_in users(:admin)
delete user_registration_path
assert_redirected_to edit_user_registration_path
assert_equal t("registrations.destroy.sole_admin"), flash[:alert]
assert User.exists?(users(:admin).id)
end
test "non-admin can delete account" do
sign_in users(:alice)
assert_difference ->{ User.count }, -1 do
delete user_registration_path
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

@@ -183,7 +183,7 @@ class UsersTest < ApplicationSystemTestCase
end end
test 'delete profile' do test 'delete profile' do
user = sign_in user: users.reject(&:admin?).select(&:confirmed?).sample user = sign_in
# 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
unless has_current_path?(edit_user_registration_path) unless has_current_path?(edit_user_registration_path)
@@ -196,14 +196,6 @@ class UsersTest < ApplicationSystemTestCase
assert_text t("devise.registrations.destroyed") assert_text t("devise.registrations.destroyed")
end end
test 'sole admin cannot delete profile' do
sign_in user: users(:admin)
unless has_current_path?(edit_user_registration_path)
first(:link_or_button, users(:admin).email).click
end
assert find(:button, t("users.registrations.edit.delete"))[:disabled]
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

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)