Compare commits

..

8 Commits

Author SHA1 Message Date
68695bced9 Merge styles of <a>.button/<button>/<input type="submit"> into .button
Remove flash button
Fix some multi-selector specificity differences
2026-03-10 18:16:01 +00:00
6717b1d4c1 Update and format comments 2026-03-10 18:15:24 +00:00
d7fd8f1c45 Make [disabled] and [hidden] styles !important 2026-03-10 18:15:06 +00:00
c367009347 Regenerate credentials.yml.enc with working master.key
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:58:43 +00:00
1efb1ad86e 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-10 17:50:47 +00:00
238e8eb846 Fix controller tests and SQLite compatibility for defaults_diff
Test infrastructure:
- Allow www.example.com host in test env (ActionDispatch::HostAuthorization
  was blocking all integration test requests)
- Include Devise::Test::IntegrationHelpers in ActionDispatch::IntegrationTest
  so tests can sign in with sign_in(user)

Controller tests:
- Rewrite UsersControllerTest to match actual routes/actions (no new/create/
  edit/destroy); sign in as admin; test update-self rejection via turbo_stream
- Fix Default::UnitsControllerTest to sign in before requesting the index

SQLite compatibility in Unit#defaults_diff:
- Hoist the inner "units" CTE to the outer WITH RECURSIVE level (fixes nested
  WITH syntax error) — this was the existing TODO in the code
- Use Unit.joins(...) for the recursive part instead of a raw Arel::SelectManager
  so the SQLite visitor does not wrap it in parentheses inside UNION ALL
- Drop the named "units" CTE (conflicts with the table name under WITH RECURSIVE
  in SQLite); apply the user/defaults scope directly on the base case
- Qualify GROUP BY columns to avoid ambiguity when bases_units is joined
- Qualify ORDER BY :multiplier/:symbol to avoid ambiguity (Unit.ordering)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:50:19 +00:00
37199f85df Use committed database.yml instead of generating it in CI
The repo's config/database.yml already handles both SQLite (default) and
MySQL (DB_ADAPTER=mysql) via ERB. Remove the redundant steps that overwrote
it with a hardcoded version, and pass DB_ADAPTER=mysql for the MySQL job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:50:19 +00:00
7e1eacbc33 Add multi-adapter test support: SQLite + MySQL via Gitea Actions and rake task
- .gitea/workflows/test.yml: two parallel CI jobs (SQLite and MySQL),
  each generates its own database.yml inline and runs the test suite
- lib/tasks/test_multi_db.rake: `rails test:all_adapters` runs both
  adapters sequentially using DATABASE_URL to switch at runtime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:50:19 +00:00
12 changed files with 351 additions and 201 deletions

74
.gitea/workflows/test.yml Normal file
View File

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

Before

Width:  |  Height:  |  Size: 167 B

After

Width:  |  Height:  |  Size: 152 B

View File

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

Before

Width:  |  Height:  |  Size: 293 B

After

Width:  |  Height:  |  Size: 278 B

View File

@@ -18,10 +18,14 @@
/* 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) * differently depending on context (e.g. <form>; <a> as link/button),
* * styles with multiple selectors should have all selectors with same
* specificity, to allow proper rule specificity vs order management.
* *
* NOTE: Style in a modular way, similar to how CSS @scope would be used, * 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;
@@ -53,12 +57,26 @@
:focus-visible { :focus-visible {
outline: none; outline: none;
} }
/* NOTE: move to higher priority layer instead of using !important? */
[disabled] {
border-color: var(--color-border-gray) !important;
color: var(--color-border-gray) !important;
/* NOTE: cannot set cursor with `pointer-events: none`; can be fixed by setting
* `cursor` on wrapping element.
cursor: not-allowed; */
fill: var(--color-border-gray) !important;
pointer-events: none !important;
}
/* [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,
@@ -73,50 +91,17 @@ 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: solid 1px var(--color-gray); border: 1px solid var(--color-gray);
border-radius: 0.25em; border-radius: 0.25em;
padding: 0.2em 0.4em;
} }
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);
@@ -124,13 +109,16 @@ 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 {
appearance: checkbox; appearance: checkbox;
-webkit-appearance: checkbox; -webkit-appearance: checkbox;
} }
/* Hide spin buttons in input number fields */ /* Hide spin buttons of <input type=number>. */
/* TODO: add spin buttons inside <input type=number>: before (-) and after (+) input. */
input[type=number] { input[type=number] {
appearance: textfield; appearance: textfield;
-moz-appearance: textfield; -moz-appearance: textfield;
@@ -142,52 +130,21 @@ 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);
} }
.button:hover,
button:hover,
input[type=submit]:hover {
background-color: var(--color-blue);
border-color: var(--color-blue);
color: white;
fill: white;
}
.dangerous:hover {
background-color: var(--color-red);
border-color: var(--color-red);
}
input:hover, input:hover,
select:hover, select:hover,
summary:hover, summary:hover,
textarea:hover { textarea:hover {
border-color: var(--color-blue); border-color: var(--color-blue);
outline: solid 1px var(--color-blue); outline: 1px solid var(--color-blue);
} }
select:hover, select:hover,
summary:hover { summary:hover {
@@ -197,8 +154,68 @@ input:invalid,
select:invalid, select:invalid,
textarea:invalid { textarea:invalid {
border-color: var(--color-red); border-color: var(--color-red);
outline: solid 1px 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);
border-color: var(--color-blue);
color: white;
fill: white;
}
.dangerous:hover {
background-color: var(--color-red);
border-color: var(--color-red);
}
/* 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. */
input[type=text]:read-only, input[type=text]:read-only,
textarea:read-only { textarea:read-only {
border: none; border: none;
@@ -206,8 +223,8 @@ textarea:read-only {
} }
/* NOTE: collapse gaps around empty rows (`topside`) once possible /* NOTE: collapse gaps around empty rows (`topside`) once possible with
* with grid-collapse property and remove alternative grid-template * `grid-collapse` property and remove alternative `grid-template-areas`.
* https://github.com/w3c/csswg-drafts/issues/5813 */ * https://github.com/w3c/csswg-drafts/issues/5813 */
body { body {
display: grid; display: grid;
@@ -240,18 +257,14 @@ header {
margin-inline-start: 4%; margin-inline-start: 4%;
} }
.navigation > .tab { .navigation > .tab {
border-bottom: solid 2px var(--color-nav-gray); border-bottom: 2px solid 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: solid 4px var(--color-blue); border-bottom: 4px solid var(--color-blue);
color: var(--color-blue); color: var(--color-blue);
fill: var(--color-blue); fill: var(--color-blue);
} }
@@ -283,7 +296,7 @@ header {
#flashes { #flashes {
display: grid; display: grid;
gap: 0.2em; row-gap: 0.4em;
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;
@@ -299,48 +312,42 @@ 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.alert:before { .flash:before {
content: url('pictograms/alert-outline.svg'); filter: invert();
height: 1.4em; height: 1.4em;
margin: 0 0.5em; margin: 0 0.5em;
width: 1.4em; width: 1.4em;
} }
.flash.alert:before {
content: url('pictograms/alert-outline.svg');
}
.flash.alert { .flash.alert {
border-color: var(--color-red); border-color: var(--color-red);
background-color: var(--color-red); background-color: var(--color-red);
} }
.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 > div { .flash svg {
grid-column: 2;
}
/* NOTE: currently flash button inherits some unnecessary styles from generic
* button. */
.flash > button {
border: none;
color: inherit;
cursor: pointer; cursor: pointer;
font-size: 1.4em; fill: white;
font-weight: bold; height: 2.2em;
grid-column: 3;
opacity: 0.6; opacity: 0.6;
padding: 0.2em 0.4em; padding: 0.4em 0.5em;
width: 2.4em;
} }
.flash > button:hover { .flash svg:hover {
opacity: 1; opacity: 1;
} }
/* TODO: Hover over invalid should work like in measurements (thin vs thick border) */
.labeled-form { .labeled-form {
align-items: center; align-items: center;
display: grid; display: grid;
@@ -357,7 +364,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);
} }
@@ -371,15 +378,23 @@ header {
} }
.labeled-form input[type=submit] { .labeled-form input[type=submit] {
font-size: 1rem; font-size: 1rem;
margin: 1.5em auto 0 auto; margin: 1em 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: solid 1px var(--color-border-gray); border: 1px solid 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;
@@ -402,7 +417,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;
@@ -432,7 +447,7 @@ table.items td:last-child {
padding-inline-end: 0.1em; padding-inline-end: 0.1em;
} }
table.items td { table.items td {
border-top: solid 1px var(--color-border-gray); border-top: 1px solid var(--color-border-gray);
height: 2.4em; height: 2.4em;
padding-block: 0.1em; padding-block: 0.1em;
} }
@@ -451,7 +466,7 @@ table.items tr.dropzone::after {
content: ''; content: '';
inset: 1px 0 0 0; inset: 1px 0 0 0;
position: absolute; position: absolute;
outline: dashed 2px var(--color-blue); outline: 2px dashed 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);
} }
@@ -462,8 +477,8 @@ table.items tr.form td {
vertical-align: top; vertical-align: top;
} }
/* TODO: replace :hover:focus-visible combos with proper LOVE stye order */ /* TODO: replace `:hover:focus-visible` combos with proper LOVE style 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 {
@@ -501,9 +516,7 @@ 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;
@@ -513,30 +526,26 @@ 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
* hover works properly without using :not(:hover) selectors here. */ table input,
table.items .button:not(:hover), table select,
table.items button:not(:hover), table summary,
table.items input:not(:hover), table textarea {
table.items select:not(:hover),
table.items textarea:not(:hover) {
border-color: var(--color-border-gray); border-color: var(--color-border-gray);
} }
table.items .button:not(:hover), table select {
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.items select:focus-within, table select:hover,
table.items select:focus-visible { table select:focus-within,
table select:focus-visible {
color: black; color: black;
} }
form a[name=cancel] { table .button {
border-color: var(--color-border-gray); border-color: var(--color-border-gray);
color: var(--color-nav-gray); color: var(--color-table-gray);
fill: var(--color-nav-gray);
} }
form table.items { form table.items {
border: none; border: none;
} }
@@ -548,6 +557,9 @@ 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 {
@@ -580,17 +592,6 @@ form table.items td:first-child {
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 {
@@ -618,7 +619,7 @@ summary:has(.button) {
padding-inline-end: 0; padding-inline-end: 0;
} }
summary .button { summary .button {
border: solid 1px var(--color-border-gray); border: 1px solid 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;
@@ -634,10 +635,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: solid 1px var(--color-border-gray); border: 1px solid 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;
@@ -659,3 +660,10 @@ 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

@@ -12,6 +12,12 @@ 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)
@@ -72,13 +78,8 @@ module ApplicationHelper
end end
def labeled_form_for(record, options = {}, &block) def labeled_form_for(record, options = {}, &block)
extra_options = {builder: LabeledFormBuilder, extra_options = {builder: LabeledFormBuilder, html: {class: 'labeled-form'}}
data: {turbo: false}, form_for(record, **merge_attributes(options, extra_options), &block)
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
@@ -113,8 +114,12 @@ module ApplicationHelper
end end
def button(value = nil, options = {}, &block) def button(value = nil, options = {}, &block)
# button does not use #objectify_options # #button does not use #objectify_options/@default_options
options.merge!(@options.slice(:form)) value, options = nil, value if value.is_a?(Hash)
options = options.merge(
@default_options.slice(:form),
class: @template.class_names('button', options[:class])
)
super super
end end
@@ -135,20 +140,21 @@ 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)
options.merge!(builder: TabularFormBuilder, skip_default_ids: true) extra_options = {builder: TabularFormBuilder, skip_default_ids: true}
options = merge_attributes(options, extra_options)
# TODO: set error message with setCustomValidity instead of rendering to flash? # 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)
options = options.deep_merge(builder: TabularFormBuilder, extra_options = {builder: TabularFormBuilder, html: {autocomplete: 'off'}}
html: {autocomplete: 'off'}) form_with(**merge_attributes(options, extra_options), &block)
form_with(**options, &block)
end end
def svg_tag(source, label = nil, options = {}) def svg_tag(source, label = nil, options = {})
svg_tag = tag.svg(options) do label, options = nil, label if label.is_a? Hash
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)
@@ -159,6 +165,7 @@ 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],
] ]
@@ -206,6 +213,7 @@ 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
@@ -215,8 +223,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.div(sanitize(message)) + tag.button(sanitize("&times;"), tabindex: -1, tag.span(sanitize(message)) +
onclick: "this.parentElement.remove();") svg_tag('pictograms/close-outline', {onclick: "this.parentElement.remove()"})
end end
end end
end.join.html_safe end.join.html_safe
@@ -252,4 +260,11 @@ 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

@@ -26,10 +26,8 @@ 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: [
Unit.with(units: self.or(Unit.defaults)).left_joins(:base) 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)
@@ -65,8 +63,14 @@ 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.
arel_table.join(actionable_units).on(actionable_units[:base_id].eq(arel_table[:id])) # Use ActiveRecord::Relation (not a raw SelectManager) so the SQLite Arel
.project(arel_table[Arel.star], Arel::Nodes.build_quoted(nil).as('portable')) # visitor does not wrap it in parentheses inside the UNION ALL CTE body.
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()
@@ -74,7 +78,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(:base_id, :symbol) .from(units).group(units[:base_id], units[:symbol])
} }
scope :ordered, ->{ scope :ordered, ->{
left_outer_joins(:base).order(ordering) left_outer_joins(:base).order(ordering)
@@ -83,8 +87,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),
:multiplier, arel_table[:multiplier],
:symbol] arel_table[:symbol]]
end end
before_destroy do before_destroy do

View File

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

View File

@@ -58,4 +58,7 @@ 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

@@ -0,0 +1,50 @@
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,8 +1,12 @@
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 units_defaults_index_url get default_units_url
assert_response :success assert_response :success
end end
end end

View File

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

View File

@@ -2,6 +2,10 @@ 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)