7 Commits

Author SHA1 Message Date
cryptogopher 4d39b6075c Separate items-table and tabular-form styling 2026-11-06 22:40:30 +01:00
cryptogopher 6e0148ecdc Prevent table columns' width change on form toggle 2026-11-05 18:00:34 +01:00
cryptogopher 363571a336 Fix SQLite query compatibility inside WITH clause
Rename model methods to #succeeding.
2026-10-27 06:22:55 +01:00
cryptogopher 31db40fae8 Update tests for new content layout of index tables
Use unreleased and custom :table_row selector features.
2026-10-27 06:22:55 +01:00
cryptogopher 9c51887024 Improve index tables
Stretch inputs to column width in place of fixed sizing.
Set minimum column widths for columns with form inputs.
Simplify table operations by removing `thead` and adding sentinel.
Manage empty table display with CSS only.
2026-10-17 19:25:46 +02:00
cryptogopher 2c845f3528 Replace patched #dom_id with #dom_target 2026-10-17 05:06:09 +02:00
cryptogopher f1578fad75 Update and move turbo_stream actions to separate file
Use CSS `visibility` instead of `display` for #show/#hide.
Access data attributes through `dataset` property.
2026-10-17 03:14:35 +02:00
43 changed files with 490 additions and 404 deletions
+3 -1
View File
@@ -40,7 +40,9 @@ end
group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara"
# TODO: move back from git after this PR is included:
# https://github.com/teamcapybara/capybara/pull/2862
gem "capybara", git: "https://github.com/teamcapybara/capybara", branch: "table_row-optional-header-and-integer-locator"
gem "selenium-webdriver"
end
+16 -10
View File
@@ -6,6 +6,21 @@ GIT
i18n (1.15.2)
concurrent-ruby (~> 1.0)
GIT
remote: https://github.com/teamcapybara/capybara
revision: 3e9a2bff144fc5dc980e7f5c9dce31a13e3cad31
branch: table_row-optional-header-and-integer-locator
specs:
capybara (3.40.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.11)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
GEM
remote: https://rubygems.org/
specs:
@@ -92,15 +107,6 @@ GEM
builder (3.3.0)
byebug (13.0.0)
reline (>= 0.6.0)
capybara (3.40.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.11)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
concurrent-ruby (1.3.8)
connection_pool (3.0.2)
crass (1.0.7)
@@ -318,7 +324,7 @@ PLATFORMS
DEPENDENCIES
byebug
capybara
capybara!
devise (~> 5.0.4)
i18n!
importmap-rails
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M19.78,2.2L24,6.42L8.44,22L0,13.55L4.22,9.33L8.44,13.55L19.78,2.2M19.78,5L8.44,16.36L4.22,12.19L2.81,13.55L8.44,19.17L21.19,6.42L19.78,5Z" /></svg>

After

Width:  |  Height:  |  Size: 227 B

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="icon" viewBox="0 0 24 24"><path d="M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M13,7H11V11H7V13H11V17H13V13H17V11H13V7Z" /></svg>

Before

Width:  |  Height:  |  Size: 297 B

+56 -21
View File
@@ -24,6 +24,7 @@
*
* NOTE: style in a modular way, similar to how CSS @scope would be used,
* to make transition easier once @scope is widely available. */
/* TODO: change :root -> <html> ? */
:root {
--color-focus-gray: #f3f3f3;
--color-border-gray: #dddddd;
@@ -71,10 +72,20 @@
fill: var(--color-border-gray) !important;
pointer-events: none !important;
}
/* Styles set `display` without distinguishing between [hidden] elements, making
* them visible. */
/* For hidden elements that need to influence layout and/or are only temporarily
* hidden use [hidden=until-found] instead of [hidden] to:
* - avoid hiding with `display: none` as `display` may be overridden by
* selectors with greater specificity and element may become visible,
* - use `visibility: collapse` for hiding, to avoid excessive resizing.
* TODO: change to `until-found` after upgrade to Rails > 8.1.4 (#59338)
* - change hidden values in index <tr>s
* - check if toggleAttribute("hidden") works properly (use turbo action #hide?)
[hidden=until-found] {
visibility: collapse;
}*/
[hidden] {
display: none !important;
display: revert;
visibility: collapse;
}
@@ -146,6 +157,11 @@ input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
table {
border: none;
border-spacing: 0;
font-size: 0.85rem;
}
/* Text color of table form controls:
* - black for row/table forms,
* - inherited for internal (column specific) buttons/forms. */
@@ -171,6 +187,7 @@ table svg:not(:only-child) {
width: 1.25em;
}
input:focus-visible,
/* TODO: select:focus-visible is redundant with -within? */
select:focus-visible,
select:focus-within,
/* TODO: how to achieve `summary:focus-within` for `::details-content`? */
@@ -227,6 +244,7 @@ pre {
display: flex;
fill: var(--color-gray);
font-weight: bold;
justify-content: center;
}
.button {
border: 1px solid var(--color-gray);
@@ -250,6 +268,7 @@ table .button {
font-weight: normal;
height: 100%;
padding: 0.4em;
width: 100%;
}
.button:focus-visible,
.tab:focus-visible,
@@ -301,6 +320,7 @@ header {
grid-area: header;
}
/* TODO: change .navigation -> <nav> ? */
.navigation {
display: flex;
grid-area: nav;
@@ -442,10 +462,7 @@ header {
padding-block: 0;
}
.tabular-form table {
border: none;
border-spacing: 0;
}
.tabular-form table td {
border: none;
padding-inline-start: 0.4em;
@@ -457,26 +474,25 @@ header {
.tabular-form table td:last-child {
padding-inline-end: 0;
}
.tabular-form table :is(form, input, select, textarea):only-child {
margin-inline-start: 0;
}
.items-table {
border-spacing: 0;
/* FIXME: <button> has different height (28px) inside <td> than <a> (29px) */
.items-table:has(> tbody > tr > td) {
border: 1px solid var(--color-border-gray);
border-radius: 0.25em;
font-size: 0.85rem;
text-align: left;
}
.items-table thead {
font-size: 0.8rem;
.items-table:has(> tbody > tr > td) caption {
display: none;
}
.items-table thead,
.items-table tbody tr:hover {
.items-table tbody:not(:has(> tr > td)) {
visibility: collapse;
}
.items-table th,
.items-table tr:where(:hover) {
background-color: var(--color-focus-gray);
}
.items-table th {
font-size: 0.8rem;
padding: 0.75em 0 0.75em 1em;
text-align: center;
}
@@ -486,13 +502,18 @@ header {
.items-table td {
border-top: 1px solid var(--color-border-gray);
height: 2.4em;
padding: 0.1em 0 0.1em calc(1em + var(--depth) * 0.8em);
padding: 0.1em 0.5em;
}
.items-table td[style*="--depth"] {
padding-inline-start: calc(1em + var(--depth) * 0.8em);
}
.items-table td:has(> .button, > :where(form) > .button),
.items-table td:last-child {
padding-inline-end: 0.1em;
}
.items-table :is(form, input, select, textarea):only-child {
margin-inline-start: calc(-0.4em - 0.9px);
/* Vertically align text inside form controls with ordinary data cells. */
.items-table :is(input, select, textarea) {
margin-inline: calc(-0.4em - 0.9px);
}
/* For <a> to fill table cell completely, we use an `::after` pseudoelement. */
/* TODO: expand to whole row? will require adjusting z-index on inputs/buttons */
@@ -560,6 +581,20 @@ header {
.hmin50 {
min-width: 50%;
}
.hminnumber {
min-width: 5em;
}
.hmintext {
min-width: 6em;
}
.hstretch {
/* `width` conflicts with <input>/<textarea> size defined by [size]/[cols]
* attributes. It's best to not use these attributes (e.g. set them to 1) when
* setting width through stylesheet. Minimum width can be set with `min-width`. */
/* NOTE: `field-sizing` can be considered after upgrade do FF >=152 */
width: -moz-available;
width: stretch;
}
.italic {
color: var(--color-gray);
font-style: italic;
+4 -3
View File
@@ -19,7 +19,7 @@ class QuantitiesController < ApplicationController
def create
@quantity = current_user.quantities.new(quantity_params)
if @quantity.save
@before = @quantity.successive
@before = @quantity.succeeding
@ancestors = @quantity.ancestors
flash.now.notice = t('.success', quantity: @quantity)
else
@@ -45,8 +45,9 @@ class QuantitiesController < ApplicationController
if @quantity.update(permitted)
@ancestors = @quantity.ancestors
@self_and_progenies = @quantity.with_progenies
@before = @self_and_progenies.last.successive
# TODO: simplify into 1 query (see #succeeding comment)
@quantities = @quantity.with_progenies
@before = @quantities.last.succeeding
else
# Until UI blocks all disallowed reparents, render error messages if present.
flash.now.alert = @quantity.errors.full_messages
+2 -2
View File
@@ -18,7 +18,7 @@ class UnitsController < ApplicationController
def create
@unit = current_user.units.new(params.expect(Unit::ATTRIBUTES))
if @unit.save
@before = @unit.successive
@before = @unit.succeeding
flash.now.notice = t('.success', unit: @unit)
else
render_errors @unit
@@ -45,7 +45,7 @@ class UnitsController < ApplicationController
@previous_base = @unit.base
@unit.update!(unit_params)
@before = @unit.successive
@before = @unit.succeeding
if @unit.multiplier_previously_changed?
flash.now.notice = t(".multiplier_reset", unit: @unit)
end
+8 -3
View File
@@ -92,6 +92,10 @@ module ApplicationHelper
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{selector}(method, options = {})
options[:maxlength] ||= object.type_for_attribute(method).limit
# If [cols]/[size] is not set, minimum field width is controlled with
# stylesheet. To prevent browser estimating missing [cols]/[size]
# based on e.g. [maxlength], set it to 1.
options[:#{selector == :text_area ? :cols : :size}] ||= 1
super
end
RUBY_EVAL
@@ -103,7 +107,7 @@ module ApplicationHelper
options[:min] ||= Float::MIN_15.to_scientific
options[:max] ||= Float::MAX_15.to_scientific
options[:oninput] = 'numberValidate(event)'
options[:size] ||= 6
options[:size] ||= options[:class]&.split&.include?('hstretch') ? 1 : 6
options[:step] ||= :any
options[:value] = object.public_send("#{method}_before_type_cast")
end
@@ -123,8 +127,7 @@ module ApplicationHelper
private
def submit_default_value
svg_name = object ? (object.persisted? ? 'update' : 'plus-circle-outline') : ''
@template.svg_tag(svg_name, super)
@template.svg_tag('check-outline', I18n.t(:"save"))
end
end
@@ -147,6 +150,8 @@ module ApplicationHelper
def svg_tag(source, label = nil, options = {})
label, options = nil, label if label.is_a? Hash
svg_tag = tag.svg(**options) do
# TODO: move pictograms to 1 file with unique IDs instead of `icon`
# TODO: add test/rake task to detect unused and missing pictograms
tag.use(href: image_path("#{source}.svg") + '#icon')
end
label.blank? ? svg_tag : svg_tag + tag.span(label)
+3 -100
View File
@@ -1,6 +1,7 @@
// Configure your import map in config/importmap.rb. Read more:
// https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "./turbo_actions.js"
// TODO: access `data-` attributes through
// `keyname in`/`dataset.keyname`/`delete dataset.keyname`
@@ -77,111 +78,13 @@ function numberValidate(event) {
window.numberValidate = numberValidate
// Turbo stream actions.
const FORM_CONTROLS = ["BUTTON", "FIELDSET", "INPUT", "OPTGROUP", "OPTION", "SELECT",
"TEXTAREA"]
Turbo.StreamElement.prototype.disableElement = function(element) {
if (FORM_CONTROLS.includes(element.tagName)) {
element.setAttribute("disabled", "disabled")
} else {
element.setAttribute("aria-disabled", "true")
// NOTE: tabindex should not be disabled?
element.setAttribute("tabindex", "-1")
}
}
Turbo.StreamActions.disable = function() {
this.targetElements.forEach((e) => { this.disableElement(e) })
}
Turbo.StreamElement.prototype.enableElement = function(element) {
if (FORM_CONTROLS.includes(element.tagName)) {
element.removeAttribute("disabled")
} else {
element.removeAttribute("aria-disabled")
// Assume 'tabindex' is not used explicitly, so removing it is safe.
element.removeAttribute("tabindex")
}
}
Turbo.StreamActions.enable = function() {
this.targetElements.forEach((e) => { this.enableElement(e) })
}
// TODO: change to visibility = collapse to avoid width change?
Turbo.StreamActions.hide = function() {
this.targetElements.forEach((e) => { e.style.display = "none" })
}
Turbo.StreamActions.show = function() {
this.targetElements.forEach((e) => { e.style.removeProperty("display") })
}
/*
Turbo.StreamActions.collapse = function() {
this.targetElements.forEach((e) => { e.style.visibility = "collapse" })
}
*/
Turbo.StreamActions.close_form = function() {
this.targetElements.forEach((e) => {
// Move focus if there's no focus or focus inside form being closed.
const focused = document.activeElement
if (!focused || (focused == document.body) || e.contains(focused)) {
let nextForm = e.parentElement.querySelector(`#${e.id} ~ tr:has([autofocus])`)
nextForm ??= e.parentElement.querySelector("tr:has([autofocus])")
nextForm?.querySelector("[autofocus]").focus()
}
document.getElementById(e.getAttribute("data-form")).remove()
if (e.hasAttribute("data-link")) {
this.enableElement(document.getElementById(e.getAttribute("data-link")))
}
if (e.hasAttribute("data-hidden-row")) {
document.getElementById(e.getAttribute("data-hidden-row")).removeAttribute("style")
}
e.remove()
})
}
Turbo.StreamActions.unselect = function() {
this.targetElements.forEach((e) => {
e.checked = false
this.enableElement(e)
})
}
Turbo.StreamActions.validate = function() {
this.targetElements.forEach((form) => {
this.templateContent.querySelectorAll("span").forEach((span) => {
var control = document.querySelector(
`[form="${form.id}"][name="${span.dataset.name}"]`
)
control.setCustomValidity(span.textContent)
// Add event listener in capture phase to clear custom validity before
// `[oninput]` or any other bubble phase handler is invoked.
// https://www.quirksmode.org/js/events_order.html
// TODO: keep invalidating current value whenever user re-enters it. Store
// invalid value and validation message in `[data-invalid-message/value]`
// and check input value on "input" event (below) (using `[pattern]` may not
// be suitable due to inability to customize validtion message; also
// pattern may be used for other, conflicting purposes).
// After user executes any other action (while form is open), remove `[data-]`
// attributes and custom validation, as value may no longer be invalid.
control.addEventListener(
"input",
function(event) { event.target.setCustomValidity("") },
{capture: true, once: true}
)
})
form.reportValidity()
})
}
// Keyboard event processing handlers.
function formProcessKey(event) {
switch (event.key) {
case "Escape":
event.currentTarget.querySelector("a[name=cancel]").click()
event.currentTarget.querySelector("a[name=discard]").click()
break
// TODO: allow new line inside <textarea> (submit with ctrl-enter?)
case "Enter":
event.currentTarget.querySelector("button[name=button]").click()
event.preventDefault()
+97
View File
@@ -0,0 +1,97 @@
// Turbo Streams actions.
const FORM_CONTROLS = ["BUTTON", "FIELDSET", "INPUT", "OPTGROUP", "OPTION", "SELECT",
"TEXTAREA"]
Object.assign(Turbo.StreamElement.prototype, {
disableElement(element) {
if (FORM_CONTROLS.includes(element.tagName)) {
element.setAttribute("disabled", "disabled")
} else {
element.setAttribute("aria-disabled", "true")
// NOTE: tabindex should not be disabled?
element.setAttribute("tabindex", "-1")
}
},
enableElement(element) {
if (FORM_CONTROLS.includes(element.tagName)) {
element.removeAttribute("disabled")
} else {
element.removeAttribute("aria-disabled")
// Assume 'tabindex' is not used explicitly, so removing it is safe.
element.removeAttribute("tabindex")
}
},
showElement(element) { element.style.visibility = "visible" }
})
Object.assign(Turbo.StreamActions, {
disable() {
this.targetElements.forEach((e) => { this.disableElement(e) })
},
enable() {
this.targetElements.forEach((e) => { this.enableElement(e) })
},
hide() {
this.targetElements.forEach((e) => { e.style.visibility = "collapse" })
},
show() {
this.targetElements.forEach((e) => { this.showElement(e) })
},
unselect() {
this.targetElements.forEach((e) => { e.checked = false; this.enableElement(e) })
},
validate_form() {
this.targetElements.forEach((form) => {
this.templateContent.querySelectorAll("span").forEach((span) => {
var control = document.querySelector(
`[form="${form.id}"][name="${span.dataset.name}"]`
)
control.setCustomValidity(span.textContent)
// Add event listener in capture phase to clear custom validity before
// `[oninput]` or any other bubble phase handler is invoked.
// https://www.quirksmode.org/js/events_order.html
// TODO: keep invalidating current value whenever user re-enters it. Store
// invalid value and validation message in `[data-invalid-message/value]`
// and check input value on "input" event (below) (using `[pattern]` may not
// be suitable due to inability to customize validtion message; also
// pattern may be used for other, conflicting purposes).
// After user executes any other action (while form is open), remove `[data-]`
// attributes and custom validation, as value may no longer be invalid.
control.addEventListener(
"input",
function(event) { event.target.setCustomValidity("") },
{capture: true, once: true}
)
})
form.reportValidity()
})
},
close_form() {
this.targetElements.forEach((e) => {
// Move focus if there's no focus or focus inside form being closed.
const focused = document.activeElement
if (!focused || (focused == document.body) || e.contains(focused)) {
let nextForm = e.parentElement.querySelector(`#${e.id} ~ tr:has([autofocus])`)
nextForm ??= e.parentElement.querySelector("tr:has([autofocus])")
nextForm?.querySelector("[autofocus]").focus()
}
document.getElementById(e.dataset.form).remove()
if ('link' in e.dataset) {
this.enableElement(document.getElementById(e.dataset.link))
}
if ('hiddenRow' in e.dataset) {
this.showElement(document.getElementById(e.dataset.hiddenRow))
}
e.remove()
})
}
})
+45 -22
View File
@@ -18,8 +18,13 @@ class Quantity < ApplicationRecord
end
validates :name, presence: true, uniqueness: {scope: [:user_id, :parent_id]},
length: {maximum: type_for_attribute(:name).limit}
# FIXME: length validation for :text type column is probably wrong; reported
# limit is in bytes (at least for MySQL) and check is for #length (i.e.
# characters); this doesn't seem to apply to :string (implemented as varchar)
validates :description, length: {maximum: type_for_attribute(:description).limit}
# TODO: initialize `depth` to `parent.depth + 1`
# Update :depths of progenies after parent change
before_save if: :parent_changed? do
self[:depth] = parent&.depth&.succ || 0
@@ -62,26 +67,42 @@ class Quantity < ApplicationRecord
scope :defaults, ->{ where(user: nil) }
# Return: ordered [sub]hierarchy
scope :ordered, ->(root: nil, include_root: true) {
numbered = Arel::Table.new('numbered')
scope :ordered, ->(**kwargs) {
with_path(**kwargs).order(arel_table[:path])
}
self.model.with(numbered: numbered(:parent_id, :name)).with_recursive(arel_table.name => [
numbered.project(
numbered[Arel.star],
numbered.cast(numbered[:child_number], 'BINARY').as('path')
).where(numbered[root && include_root ? :id : :parent_id].eq(root)),
numbered.project(
numbered[Arel.star],
arel_table[:path].concat(numbered[:child_number])
).join(arel_table).on(numbered[:parent_id].eq(arel_table[:id]))
]).order(arel_table[:path])
# TODO: sort recursive CTE using ORDER BY; it's supported by SQLite, so
# drop MySQL until it is available (?)
# Only ORDER BY allows proper i.e. not placing limits on user data and
# efficient with regard to paging (LIMIT/OFFSET) depth-first search.
# Sorting based on numbering is less elegant/efficient (MySQL: ROW_NUMBER cannot
# be used inside recursive CTE and needs separate SELECT,
# https://bugs.mysql.com/bug.php?id=96538).
# Sorting based on pathname requires I) padding to maximum column length or
# II) delimiter which a) cannot be used in sorted column and b) sorts before
# anything else (this adds complexity; NULL cannot be used when sorting).
# Sorting based on ARRAY is only available on PostgreSQL.
scope :with_path, ->(root: nil, include_root: true) {
with_number = Arel::Table.new('with_number')
self.model.with(with_number: with_number(:parent_id, :name))
.with_recursive(arel_table.name => [
with_number.project(
with_number[Arel.star],
with_number.cast(with_number[:number], 'BINARY').as('path')
).where(with_number[root && include_root ? :id : :parent_id].eq(root)),
with_number.project(
with_number[Arel.star],
arel_table[:path].concat(with_number[:number])
).join(arel_table).on(with_number[:parent_id].eq(arel_table[:id]))
])
}
# TODO: extract named functions to custom Arel extension
# NOTE: once recursive queries allow use of window functions, this scope can
# be merged with :ordered
# https://gist.github.com/ProGM/c6df08da14708dcc28b5ca325df37ceb#extending-arel
scope :numbered, ->(parent_column, order_column) {
scope :with_number, ->(parent_column, order_column) {
select(
arel_table[Arel.star],
Arel::Nodes::NamedFunction.new(
@@ -94,7 +115,7 @@ class Quantity < ApplicationRecord
).from(arel_table),
Arel::Nodes.build_quoted('0')
],
).as('child_number')
).as('number')
)
}
@@ -132,15 +153,17 @@ class Quantity < ApplicationRecord
.order(arel_table[:depth].desc)
}
# Return: successive record in order of appearance; used for partial view reload
def successive
# Return: succeeding record in order of appearance, used for partial view reload.
# TODO: add scope returning succeeding element for multiple records
def succeeding
quantities = Quantity.arel_table
Quantity.with(
quantities: user.quantities.ordered.select(
quantities[Arel.star],
Arel::Nodes::NamedFunction.new('LAG', [quantities[:id]]).over.as('lag_id')
)
).where(quantities[:lag_id].eq(id)).first
Quantity.with(lagging_quantities: user.quantities.with_path.select(
quantities[Arel.star],
# For SQLite 'OVER () ORDER BY x' is not equivalent to 'OVER (ORDER BY x)'.
Arel::Nodes::NamedFunction.new('LAG', [quantities[:id]])
.over(Arel::Nodes::Window.new.order(Quantity.ordered.order_values)).as('lag_id')
)).from(Arel::Table.new(:lagging_quantities).as(:quantities))
.where(quantities[:lag_id].eq(id)).take
end
def with_progenies
+4 -3
View File
@@ -3,6 +3,7 @@ class Unit < ApplicationRecord
belongs_to :user, optional: true
belongs_to :base, optional: true, class_name: "Unit"
# TODO: use #alias_attribute after Rails upgrade?
alias_method :parent, :base
has_many :subunits, class_name: "Unit", inverse_of: :base,
dependent: :restrict_with_error
@@ -111,13 +112,13 @@ class Unit < ApplicationRecord
.update!(base: recipient_base, **params)
end
def successive
def succeeding
units = Unit.arel_table
Unit.with(units_with_lag: user.units.left_outer_joins(:base).select(
Unit.with(lagging_units: user.units.left_outer_joins(:base).select(
units[Arel.star],
Arel::Nodes::NamedFunction.new('LAG', [units[:id]])
.over(Arel::Nodes::Window.new.order(Unit.ordered.order_values)).as('lag_id')
)).from(Arel::Table.new(:units_with_lag).as(:units))
)).from(Arel::Table.new(:lagging_units).as(:units))
.where(units[:lag_id].eq(id)).take
end
end
@@ -1,4 +1,4 @@
<%= turbo_stream.validate dom_target(*@target, :form) do %>
<%= turbo_stream.validate_form dom_target(*@target, :form) do %>
<% @record.errors.attribute_names.map do |attr| %>
<%= tag.span data: {name: field_name(dom_class(@record), attr)} do %>
<%- @record.errors.full_messages_for(attr).map { |m| m + '.' }.join(' ') %>
+4 -3
View File
@@ -2,7 +2,7 @@
class: 'topside-area flex vertical center',
html: {onkeydown: 'formProcessKey(event)'} do |form| %>
<table class="items-table center">
<table class="center">
<tbody id="readouts">
<%= tabular_fields_for @measurement do |form| %>
<tr class="italic">
@@ -19,7 +19,8 @@
<details id="quantity_select" class="center hexpand" open
onkeydown="detailsProcessKey(event)">
<summary autofocus>
<!-- TODO: Set content with CSS when span empty to avoid duplication -->
<%# TODO: Set content with CSS when span empty to avoid duplication; or
change to disabled input with prompt %>
<span data-prompt="<%= t('.select_quantity') %>">
<%= t('.select_quantity') %>
</span>
@@ -32,7 +33,7 @@
<div class="flex reverse">
<%= form.button id: :create_measurement_button, disabled: true -%>
<%= image_link_to t(:cancel), "close-outline", measurements_path, name: :cancel,
<%= image_link_to t(:discard), "close-outline", measurements_path, name: :discard,
class: 'auxiliary dangerous', onclick: render_turbo_stream('form_close') %>
</div>
<% end %>
+12 -15
View File
@@ -1,19 +1,16 @@
<%= tabular_fields_for @quantity, form: form_tag do |form| %>
<%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {link: link, form: form_tag, hidden_row: hidden_row} do %>
<%# locals: (record: Quantity.new, **attrs) %>
<%= tabular_fields_for record, form: attrs.dig(:data, :form) do |form| %>
<%- tag.tr class: "form", onkeydown: "formProcessKey(event)", **attrs do %>
<td style="--depth:<%= @quantity.depth %>">
<%= form.text_field :name, required: true, autofocus: true, size: 20 %>
</td>
<td style="--depth:<%= form.object.depth %>">
<%= form.text_field :name, autofocus: true, class: 'hstretch', required: true %>
<td><%= form.text_area :description, class: 'hstretch', escape: false, rows: 1 %>
<td><%= form.button %>
<td>
<%= form.text_area :description, cols: 30, rows: 1, escape: false %>
</td>
<td class="flex">
<%= form.button %>
<%= image_link_to t(:cancel), "close-outline", quantities_path, class: 'dangerous',
name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %>
</td>
<td></td>
<%= image_link_to t(:discard), "close-outline", quantities_path,
class: 'dangerous', name: :discard,
onclick: render_turbo_stream('form_close', {id: attrs[:id]}) %>
<td><!-- TODO: add aria-disabled drag handle with proper vertical alignment? -->
<% end %>
<% end %>
+1 -1
View File
@@ -1,2 +1,2 @@
<%= turbo_stream.close_form row %>
<%= turbo_stream.close_form id %>
<%= turbo_stream.update :flashes %>
+15 -15
View File
@@ -1,24 +1,24 @@
<%= tag.tr id: dom_id(quantity),
ondragstart: "dragStart(event)", ondragend: "dragEnd(event)",
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drag_path: reparent_quantity_path(quantity), drop_id: dom_id(quantity),
drop_id_param: "quantity[parent_id]"} do %>
<tr id="<%= dom_target(quantity) %>"
ondragstart="dragStart(event)" ondragend="dragEnd(event)"
ondragover="dragOver(event)" ondrop="drop(event)"
ondragenter="dragEnter(event)" ondragleave="dragLeave(event)"
data-drag-path="<%= reparent_quantity_path(quantity) %>"
data-drop-id="<%= dom_target(quantity) %>"
data-drop-id-param="<%= field_name(:quantity, :parent_id) %>">
<td style="--depth:<%= quantity.depth %>">
<%= link_to quantity, edit_quantity_path(quantity), class: 'link',
onclick: 'this.blur();', data: {turbo_stream: true} %>
</td>
<td><%= quantity.description %></td>
<td><%= quantity.description %>
<% if current_user.at_least(:active) %>
<td class="flex">
<%= image_link_to t('.new_subquantity'), 'plus-outline', new_quantity_path(quantity),
id: dom_id(quantity, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>
<td>
<%= image_link_to t('.new_subquantity'), 'plus-outline',
new_quantity_path(quantity), id: dom_target(:new, quantity, :link),
onclick: 'this.blur();', data: {turbo_stream: true} %>
<td>
<%= image_button_to_if quantity.destroyable?, t('.destroy'), 'delete-outline',
quantity_path(quantity), method: :delete %>
</td>
<td class="handle" draggable="true">&#x283F</td>
<td class="handle" draggable="true">&#x283F
<% end %>
<% end %>
+2 -4
View File
@@ -1,7 +1,5 @@
<%= turbo_stream.close_form dom_id(@quantity.parent || Quantity, :new) %>
<%= turbo_stream.remove :no_items %>
<%= turbo_stream.close_form dom_target(:new, @quantity.parent || :quantity) %>
<% @ancestors.each do |ancestor| %>
<%= turbo_stream.replace ancestor %>
<% end %>
<%= @before ? turbo_stream.before(@before, @quantity) :
turbo_stream.append(:quantities, @quantity) %>
<%= turbo_stream.before @before || :endmark, @quantity %>
@@ -2,4 +2,3 @@
<%= turbo_stream.replace ancestor %>
<% end %>
<%= turbo_stream.remove @quantity %>
<%= turbo_stream.append(:quantities, render_no_items) if current_user.quantities.empty? %>
+9 -9
View File
@@ -1,13 +1,13 @@
<% ids = {row: dom_id(@quantity, :edit),
hidden_row: dom_id(@quantity),
link: nil,
form_tag: dom_id(@quantity, :edit, :form)} %>
<% attrs = {record: @quantity,
id: dom_target(:edit, @quantity),
data: {form: dom_target(:edit, @quantity, :form),
hidden_row: dom_target(@quantity),
link: nil}} %>
<%= turbo_stream.append :quantity_form do %>
<%- tabular_form_with model: @quantity, html: {id: ids[:form_tag]} do %>
<%= turbo_stream.append_all 'body' do %>
<%- tabular_form_with model: @quantity, html: {id: attrs[:data][:form]} do %>
<% end %>
<% end %>
<%= turbo_stream.hide ids[:hidden_row] %>
<%= turbo_stream.remove ids[:row] %>
<%= turbo_stream.after @quantity, partial: 'form', locals: ids -%>
<%= turbo_stream.hide attrs[:data][:hidden_row] %>
<%= turbo_stream.after @quantity, partial: 'form', locals: attrs -%>
+26 -24
View File
@@ -1,34 +1,36 @@
<div class="rightside-area buttongrid">
<% if current_user.at_least(:active) %>
<%= image_link_to t('.new_quantity'), 'plus-outline', new_quantity_path,
id: dom_id(Quantity, :new, :link), onclick: 'this.blur();',
data: {turbo_stream: true} %>
id: :new_quantity_link, onclick: 'this.blur();', data: {turbo_stream: true} %>
<% end %>
<%#= image_link_to t('.import_quantities'), 'download-outline', default_quantities_path,
class: 'tools-area' %>
<%#= image_link_to t('.import_quantities'), 'download-outline',
default_quantities_path, class: 'tools-area' %>
</div>
<%# TODO: remove? form can be inserted directly, e.g. at the end of index %>
<%= tag.div class: 'main-area', id: :quantity_form %>
<table class="main-area items-table">
<thead>
<caption class="hint"><%= t '.no_items' %></caption>
<tbody>
<tr>
<th><%= Quantity.human_attribute_name(:name) %></th>
<th class="hexpand"><%= Quantity.human_attribute_name(:description) %></th>
<% if current_user.at_least(:active) %>
<th><%= t :actions %></th>
<th></th>
<% end %>
</tr>
<%= tag.tr id: "quantity_", hidden: true,
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drop_id: "quantity_", drop_id_param: "quantity[parent_id]"} do %>
<th colspan="4"><%= t '.top_level_drop' %></th>
<th class="hmintext"><%= Quantity.human_attribute_name(:name) %>
<th class="hexpand"><%= Quantity.human_attribute_name(:description) %>
<% if current_user.at_least(:active) %>
<th colspan="3"><%= t :actions %>
<tr id="quantity_" hidden
ondragover="dragOver(event)" ondrop="drop(event)"
ondragenter="dragEnter(event)" ondragleave="dragLeave(event)"
data-drop-id="quantity_"
data-drop-id-param="<%= field_name(:quantity, :parent_id) %>">
<th colspan="5"><%= t '.top_level_drop' %>
<% end %>
</thead>
<tbody id="quantities">
<%= render(@quantities) || render_no_items %>
</tbody>
<%= render @quantities %>
<% if current_user.at_least(:active) %>
<tr id="endmark" hidden>
<tfoot>
<%= render partial: 'form', locals: {hidden: true} %>
<% end %>
</table>
+10 -14
View File
@@ -1,20 +1,16 @@
<% dom_obj = @quantity.parent || @quantity %>
<% ids = {row: dom_id(dom_obj, :new),
hidden_row: nil,
link: dom_id(dom_obj, :new, :link),
form_tag: dom_id(dom_obj, :new, :form)} %>
<% target_id = dom_target(:new, @quantity.parent || :quantity) %>
<% attrs = {record: @quantity,
id: target_id,
data: {form: dom_target(target_id, :form),
hidden_row: nil,
link: dom_target(target_id, :link)}} %>
<%= turbo_stream.disable ids[:link] -%>
<%= turbo_stream.disable attrs[:data][:link] -%>
<%= turbo_stream.append :quantity_form do %>
<%- tabular_form_with model: @quantity, html: {id: ids[:form_tag]} do |form| %>
<%= turbo_stream.append_all 'body' do %>
<%- tabular_form_with model: @quantity, html: {id: attrs[:data][:form]} do |form| %>
<%= form.hidden_field :parent_id if @quantity.parent_id? %>
<% end %>
<% end %>
<% if @quantity.parent_id? %>
<%= turbo_stream.remove ids[:row] %>
<%= turbo_stream.after @quantity.parent, partial: 'form', locals: ids %>
<% else %>
<%= turbo_stream.prepend :quantities, partial: 'form', locals: ids %>
<% end %>
<%= turbo_stream.after @quantity.parent || :quantity_, partial: 'form', locals: attrs %>
@@ -1,9 +1,7 @@
<% @self_and_progenies.each do |q| %>
<%= turbo_stream.remove q %>
<% end %>
<% @previous_ancestors.union(@ancestors).each do |ancestor| %>
<%= turbo_stream.replace ancestor %>
<% end %>
<% @self_and_progenies.each do |q| %>
<%= @before ? turbo_stream.before(@before, q) : turbo_stream.append(:quantities, q) %>
<% @quantities.each do |quantity| %>
<%= turbo_stream.remove quantity %>
<%= turbo_stream.before @before || :endmark, quantity %>
<% end %>
+1 -1
View File
@@ -1,4 +1,4 @@
<%= turbo_stream.close_form dom_id(@quantity, :edit) %>
<%= turbo_stream.close_form dom_target(:edit, @quantity) %>
<% @ancestors.push(@quantity).each do |ancestor| %>
<%= turbo_stream.replace ancestor %>
<% end %>
+1 -1
View File
@@ -1,6 +1,6 @@
<%# TODO: add readout reordering by dragging %>
<%= tabular_fields_for 'readouts[]', readout do |form| %>
<%- tag.tr id: dom_id(readout.quantity, :new, :readout) do %>
<%- tag.tr id: dom_target(:new, readout.quantity, :readout) do %>
<td>
<%# TODO: add grayed readout index (in separate column?) %>
<%= readout.quantity.relative_pathname(@superquantity) %>
+1 -1
View File
@@ -1,7 +1,7 @@
<%= turbo_stream.update(:measurement_form_legend) { @superquantity&.pathname } %>
<% @prev_quantities.each do |pq| %>
<%= turbo_stream.update dom_id(pq, nil, :pathname) do %>
<%= turbo_stream.update dom_target(pq, :pathname) do %>
<%= pq.relative_pathname(@superquantity) %>
<% end %>
<% end %>
+2 -2
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_target(:new, @quantity, :readout) %>
<%= render partial: 'form_repath' %>
<%= turbo_stream.unselect dom_id(@quantity) %>
<%= turbo_stream.unselect dom_target(@quantity) %>
+1 -1
View File
@@ -1,5 +1,5 @@
<% @readouts.each do |r| %>
<%= turbo_stream.disable dom_id(r.quantity) %>
<%= turbo_stream.disable dom_target(r.quantity) %>
<% end %>
<%= render partial: 'form_repath' %>
<%= turbo_stream.append :readouts do %>
+14 -17
View File
@@ -1,22 +1,19 @@
<%= tabular_fields_for @unit, form: form_tag do |form| %>
<%- tag.tr id: row, class: "form", onkeydown: "formProcessKey(event)",
data: {link: link, form: form_tag, hidden_row: hidden_row} do %>
<%# locals: (record: Unit.new, **attrs) %>
<%= tabular_fields_for record, form: attrs.dig(:data, :form) do |form| %>
<%- tag.tr class: "form", onkeydown: "formProcessKey(event)", **attrs do %>
<td style="--depth:<%= @unit.base_id? ? 1 : 0 %>">
<%= form.text_field :symbol, required: true, autofocus: true, size: 12 %>
</td>
<td style="--depth:<%= form.object.base_id? ? 1 : 0 %>">
<%= form.text_field :symbol, autofocus: true, class: 'hstretch', required: true %>
<td>
<%= form.text_area :description, cols: 30, rows: 1, escape: false %>
</td>
<td>
<%= form.number_field :multiplier, required: true if @unit.base_id? %>
</td>
<% if form.object.base_id? %>
<%= form.number_field :multiplier, class: 'hstretch', required: true %>
<% end %>
<td><%= form.text_area :description, class: 'hstretch', escape: false, rows: 1 %>
<td class="flex">
<%= form.button %>
<%= image_link_to t(:cancel), "close-outline", units_path, class: 'dangerous',
name: :cancel, onclick: render_turbo_stream('form_close', {row: row}) %>
</td>
<td></td>
<td><%= form.button %>
<td>
<%= image_link_to t(:discard), "close-outline", units_path, class: 'dangerous',
name: :discard, onclick: render_turbo_stream('form_close', {id: attrs[:id]}) %>
<td><!-- TODO: add aria-disabled drag handle with proper vertical alignment? -->
<% end %>
<% end %>
+1 -1
View File
@@ -1,2 +1,2 @@
<%= turbo_stream.close_form row %>
<%= turbo_stream.close_form id %>
<%= turbo_stream.update :flashes %>
+17 -17
View File
@@ -1,32 +1,32 @@
<%= tag.tr id: dom_id(unit),
ondragstart: "dragStart(event)", ondragend: "dragEnd(event)",
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drag_path: rebase_unit_path(unit),
drop_id: dom_id(unit.base || unit),
drop_id_param: "unit[base_id]"} do %>
<tr id="<%= dom_target(unit) %>"
ondragstart="dragStart(event)" ondragend="dragEnd(event)"
ondragover="dragOver(event)" ondrop="drop(event)"
ondragenter="dragEnter(event)" ondragleave="dragLeave(event)"
data-drag-path="<%= rebase_unit_path(unit) %>"
data-drop-id="<%= dom_target(unit.base || unit) %>"
data-drop-id-param="<%= field_name(:unit, :base_id) %>">
<td style="--depth:<%= unit.base_id? ? 1 : 0 %>">
<%= link_to unit, edit_unit_path(unit), class: 'link', onclick: 'this.blur();',
data: {turbo_stream: true} %>
</td>
<td><%= unit.description %></td>
<%# Closing <td> tag required to avoid whitespace inclusion for testing. %>
<td class="ralign"><%= unit.multiplier.to_html if unit.base_id? %></td>
<td><%= unit.description %>
<% if current_user.at_least(:active) %>
<td class="flex">
<td>
<% unless unit.base_id? %>
<%= image_link_to t('.new_subunit'), 'plus-outline', new_unit_path(unit),
id: dom_id(unit, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>
id: dom_target(:new, unit, :link), onclick: 'this.blur();',
data: {turbo_stream: true} %>
<% end %>
<td>
<%= image_button_to_if unit.movable?, t('.destroy'), 'delete-outline',
unit_path(unit), method: :delete %>
<%= image_button_to_if unit.movable?, t('.destroy'), 'delete-outline', unit_path(unit),
method: :delete %>
</td>
<% if unit.movable? %>
<td class="handle" draggable="true">&#x283F</td>
<td class="handle" draggable="true">&#x283F
<% else %>
<td></td>
<td>
<% end %>
<% end %>
<% end %>
+1 -2
View File
@@ -1,4 +1,3 @@
<%= turbo_stream.close_form dom_target(:new, @unit.base || :unit) %>
<%= turbo_stream.remove :no_items %>
<%= turbo_stream.replace @unit.base if @unit.base_id? %>
<%= @before ? turbo_stream.before(@before, @unit) : turbo_stream.append(:units, @unit) %>
<%= turbo_stream.before @before || :endmark, @unit %>
-1
View File
@@ -1,3 +1,2 @@
<%= turbo_stream.replace @unit.base if @unit.base_id? %>
<%= turbo_stream.remove @unit %>
<%= turbo_stream.append(:units, render_no_items) if current_user.units.empty? %>
+9 -9
View File
@@ -1,13 +1,13 @@
<% ids = {row: dom_target(:edit, @unit),
hidden_row: dom_target(@unit),
link: nil,
form_tag: dom_target(:edit, @unit, :form)} %>
<% attrs = {record: @unit,
id: dom_target(:edit, @unit),
data: {form: dom_target(:edit, @unit, :form),
hidden_row: dom_target(@unit),
link: nil}} %>
<%= turbo_stream.append :unit_form do %>
<%- tabular_form_with model: @unit, html: {id: ids[:form_tag]} do %>
<%= turbo_stream.append_all 'body' do %>
<%- tabular_form_with model: @unit, html: {id: attrs[:data][:form]} do %>
<% end %>
<% end %>
<%= turbo_stream.hide ids[:hidden_row] %>
<%= turbo_stream.remove ids[:row] %>
<%= turbo_stream.after @unit, partial: 'form', locals: ids -%>
<%= turbo_stream.hide attrs[:data][:hidden_row] %>
<%= turbo_stream.after @unit, partial: 'form', locals: attrs -%>
+29 -22
View File
@@ -1,34 +1,41 @@
<div class="rightside-area buttongrid">
<% if current_user.at_least(:active) %>
<%= image_link_to t('.new_unit'), 'plus-outline', new_unit_path,
id: dom_id(Unit, :new, :link), onclick: 'this.blur();', data: {turbo_stream: true} %>
id: :new_unit_link, onclick: 'this.blur();', data: {turbo_stream: true} %>
<% end %>
<%= image_link_to t('.import_units'), 'download-outline', default_units_path,
class: 'tools-area' %>
</div>
<%# TODO: remove? form can be inserted directly, e.g. at the end of index %>
<%= tag.div id: :unit_form %>
<table class="main-area items-table">
<thead>
<caption class="hint"><%= t '.no_items' %></caption>
<tbody>
<tr>
<th><%= Unit.human_attribute_name(:symbol) %></th>
<th class="hexpand"><%= Unit.human_attribute_name(:description) %></th>
<th><%= Unit.human_attribute_name(:multiplier) %></th>
<% if current_user.at_least(:active) %>
<th><%= t :actions %></th>
<th></th>
<% end %>
</tr>
<%= tag.tr id: "unit_", hidden: true,
ondragover: "dragOver(event)", ondrop: "drop(event)",
ondragenter: "dragEnter(event)", ondragleave: "dragLeave(event)",
data: {drop_id: "unit_", drop_id_param: "unit[base_id]"} do %>
<th colspan="5"><%= t '.top_level_drop' %></th>
<th class="hmintext"><%= Unit.human_attribute_name(:symbol) %>
<th class="hminnumber"><%= Unit.human_attribute_name(:multiplier) %>
<th class="hexpand"><%= Unit.human_attribute_name(:description) %>
<% if current_user.at_least(:active) %>
<th colspan="3"><%= t :actions %>
<tr id="unit_" hidden
ondragover="dragOver(event)" ondrop="drop(event)"
ondragenter="dragEnter(event)" ondragleave="dragLeave(event)"
data-drop-id="unit_" data-drop-id-param="<%= field_name(:unit, :base_id) %>">
<th colspan="6"><%= t '.top_level_drop' %>
<% end %>
</thead>
<tbody id="units">
<%= render(@units) || render_no_items %>
</tbody>
<%= render @units %>
<% if current_user.at_least(:active) %>
<%# Hidden header and `#endmark` rows allows simplier row insertion by using only
turbo_stream #after/#before instead of #after + #prepend/#before + #append. %>
<%# TODO: replace with custom turbo_stream action, e.g. #afterrow that #prepend-s
if ID has `rows` property (`rows` in object) and #after otherwise? %>
<tr id="endmark" hidden>
<tfoot>
<%# Insert form to prevent actions column width change on form toggle. %>
<%= render partial: 'form', locals: {hidden: true} %>
<% end %>
</table>
+10 -13
View File
@@ -1,20 +1,17 @@
<% target_id = dom_target(:new, @unit.base || :unit) %>
<% ids = {row: target_id,
hidden_row: nil,
link: dom_target(target_id, :link),
form_tag: dom_target(target_id, :form)} %>
<% attrs = {record: @unit,
id: target_id,
data: {form: dom_target(target_id, :form),
hidden_row: nil,
link: dom_target(target_id, :link)}} %>
<%= turbo_stream.disable ids[:link] -%>
<%= turbo_stream.disable attrs[:data][:link] -%>
<%# TODO: disable base unit delete button if subunit; same for quantities %>
<%= turbo_stream.append :unit_form do %>
<%- tabular_form_with model: @unit, html: {id: ids[:form_tag]} do |form| %>
<%= turbo_stream.append_all 'body' do %>
<%- tabular_form_with model: @unit, html: {id: attrs[:data][:form]} do |form| %>
<%= form.hidden_field :base_id if @unit.base_id? %>
<% end %>
<% end %>
<% if @unit.base_id? %>
<%= turbo_stream.remove ids[:row] %>
<%= turbo_stream.after @unit.base, partial: 'form', locals: ids %>
<% else %>
<%= turbo_stream.prepend :units, partial: 'form', locals: ids %>
<% end %>
<%= turbo_stream.after @unit.base || :unit_, partial: 'form', locals: attrs %>
+5 -3
View File
@@ -1,4 +1,6 @@
<% [@previous_base, @unit.base].uniq.compact.each do |unit| %>
<%= turbo_stream.replace unit %>
<% end %>
<%= turbo_stream.remove @unit %>
<%= turbo_stream.replace @previous_base if @previous_base %>
<%= turbo_stream.replace @unit.base if @unit.base_id? && (@previous_base&.id != @unit.base_id) %>
<%= @before ? turbo_stream.before(@before, @unit) : turbo_stream.append(:units, @unit) %>
<%= turbo_stream.before @before || :endmark, @unit %>
<%# TODO: move new subunit form as well if open %>
-4
View File
@@ -9,10 +9,6 @@ ActiveSupport.on_load :action_dispatch_system_test_case do
XPath::DSL.prepend CoreExt::XPath::DSL::NormalizeSpaceDefault
end
ActiveSupport.on_load :action_view do
ActionView::RecordIdentifier.prepend CoreExt::ActionView::RecordIdentifierWithSuffix
end
ActiveSupport.on_load :active_record do
ActiveModel::Validations::NumericalityValidator
.prepend CoreExt::ActiveModel::Validations::NumericalityValidatesPrecision
@@ -23,19 +23,15 @@ ActiveSupport.on_load :turbo_streams_tag_builder do
action :show, target, allow_inferred_rendering: false
end
#def collapse(target)
# action :collapse, target, allow_inferred_rendering: false
#end
def close_form(target)
action :close_form, target, allow_inferred_rendering: false
end
def unselect(target)
action :unselect, target, allow_inferred_rendering: false
end
def validate(target, content = nil, **rendering, &block)
action :validate, target, content, **rendering, &block
def validate_form(target, content = nil, **rendering, &block)
action :validate_form, target, content, **rendering, &block
end
def close_form(target)
action :close_form, target, allow_inferred_rendering: false
end
end
+5 -6
View File
@@ -88,8 +88,6 @@ en:
unconfirmed_email_html: >
Awaiting confirmation for:<br><em>(since %{confirmation_sent_at})</em>
submit:
create: Create
update: Update
user:
update: Update profile
layouts:
@@ -108,12 +106,12 @@ en:
new_measurement: Add measurement
quantities:
navigation: Quantities
no_items: There are no configured quantities. You can Add some or Import from defaults.
quantity:
new_subquantity: Child
destroy: Delete
index:
new_quantity: Add quantity
no_items: There are no configured quantities. You can Add some or Import from defaults.
top_level_drop: Drop here to reposition into top-level quantity
create:
success: Created new quantity "%{quantity}"
@@ -123,13 +121,13 @@ en:
success: Deleted quantity "%{quantity}"
units:
navigation: Units
no_items: There are no configured units. You can Add some or Import from defaults.
unit:
new_subunit: Subunit
destroy: Delete
index:
new_unit: Add unit
import_units: Import
new_unit: Add unit
no_items: There are no configured units. You can Add some or Import from defaults.
top_level_drop: Drop here to reposition into top-level unit
create:
success: Created new unit "%{unit}"
@@ -181,10 +179,11 @@ en:
add: Add
apply: Apply
back: Back
cancel: Cancel
delete: Delete
discard: Discard
:no: 'no'
register: Register
save: Save
sign_in: Sign in
recover_password: Recover password
resend_confirmation: Resend confirmation
@@ -1,10 +0,0 @@
module CoreExt::ActionView::RecordIdentifierWithSuffix
# TODO: replace dom_id with dom_target, then remove this override
def dom_id(object, prefix = nil, suffix = nil)
if suffix
"#{super(object, prefix)}#{::ActionView::RecordIdentifier::JOIN}#{suffix}"
else
super(object, prefix)
end
end
end
+24 -7
View File
@@ -73,6 +73,11 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
rows_conditions = rows.map { |row| match_row(row) }.reduce(:&)
xpath[match_row_count(rows.size)][rows_conditions]
end
# Override to count only data rows, to make it consistent with #match_row.
def match_row_count(size)
XPath.descendant(:tbody).descendant(:tr)[XPath.descendant(:td)].count.equals(size)
end
end
# This regex has to be only strict enough to properly reconstruct number. It
@@ -87,23 +92,33 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
# Based on Capybara :table and :table_row selectors.
Capybara.add_selector(:table_cell, locator_type: [String, Symbol]) do
xpath do |locator|
column = XPath.string.n.is(locator.to_s)
header_xp = XPath.ancestor(:table)[1].descendant(:tr)[1].descendant(:th)[column]
position_xp = XPath.position.equals(header_xp.preceding_sibling.count.plus(1))
XPath.descendant(:td).where(header_xp.boolean & position_xp)
xpath = XPath.descendant(:td)
case locator
when String, Symbol
# TODO: make sure to only include visible column headers
column = XPath.string.n.is(locator.to_s)
header_xp = XPath.ancestor(:table)[1].descendant(:tr)[1].descendant(:th)[column]
position_xp = XPath.position.equals(header_xp.preceding_sibling.count.plus(1))
xpath.where(header_xp.boolean & position_xp)
else
xpath
end
end
expression_filter(:fillable) do |xpath, value|
value ? xpath.where(XPath.descendant(:input, :select, :textarea)) : xpath
value ? xpath[XPath.descendant(:input, :select, :textarea)
.where(!XPath.attr(:type).one_of('submit', 'hidden'))] : xpath
end
node_filter(:with) do |node, expected|
node_filter(:value) do |node, expected|
value =
case expected
when Float
node.text.match(NUMBER) { |m| "#{m['base'] || '1'}e#{m['exp']&.lstrip}".to_f }
when Integer
node.to_i
else
node.text
node.text.presence || nil
end
(expected == value).tap do |result|
add_error("Expected value to be #{expected.inspect}" \
@@ -114,6 +129,8 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
Capybara.modify_selector(:table_row) do
# Beware: filters of #boolean? type are called within different context.
# TODO: add in separate FilterSet and import using #filter_set?
# TODO: replace node_filter with expression_filter `:focus-within`?
node_filter(:with_focus, :boolean) do |node, value|
node.has_selector?(':focus') == value
end
+41 -18
View File
@@ -10,6 +10,7 @@ require "application_system_test_case"
# * user with single unit,
# * user with no units.
# Users need to be active and confirmed. Units w/ and w/o description.
# TODO: use :table_row instead of css locators (tbody etc.), optionally without locator
class UnitsTest < ApplicationSystemTestCase
def sign_in(...)
@@ -35,7 +36,7 @@ class UnitsTest < ApplicationSystemTestCase
sign_in
if @user.units.empty?
assert_selector :table, rows: [[t('units.no_items')]]
assert_selector :table, caption: t('units.index.no_items'), rows: []
else
column = column(:symbol)
assert_selector :table, rows_with: @user.units.map { |u| {column => u.symbol} }
@@ -56,6 +57,8 @@ class UnitsTest < ApplicationSystemTestCase
attributes = [:symbol, :description]
attributes << :multiplier if action == :new_subunit
# TODO: verify form location and focus
# action == :new_subunit ? first(:table_row) : :table_row, after: {column(:symbol) => ...}
within :table_row, {}, with_focus: true do
attributes.map! do |name|
field = find(:table_cell, column(name)).find(:fillable_field)
@@ -76,7 +79,7 @@ class UnitsTest < ApplicationSystemTestCase
[name, value]
end
click_on t('helpers.submit.create')
click_on t(:save)
end
attributes = attributes.to_h
@@ -90,13 +93,16 @@ class UnitsTest < ApplicationSystemTestCase
multiplier = attributes.delete(:multiplier).to_f if action == :new_subunit
within :table_row, attributes.transform_keys { |k| column(k) } do
assert_selector :table_cell, column(:multiplier), with: multiplier if multiplier
assert_selector :table_cell, column(:multiplier), value: multiplier
end
end
assert_no_selector :link, disabled: true,
exact_text: Regexp.union(link_labels.values)
# TODO: 50/50 open new form and check if alert is no longer visible? #91
refresh
# TODO: change to assert_selector :table, rows: ...
assert_equal new_symbols, list_symbols
end
@@ -132,23 +138,24 @@ class UnitsTest < ApplicationSystemTestCase
sign_in(user: users.select { |u| u.confirmed? && u.units.many? }.sample)
link = all(:link_or_button, exact_text: t('units.unit.destroy')).sample
symbol = link.ancestor('tr').first(:link).text
index = link.ancestor('tbody').all('tr').index { |e| e.first(:link).text == symbol }
unit = @user.units.find_by(symbol: symbol)
symbol = link.ancestor(:table_row).find(:table_cell, column(:symbol)).text
index = all(:table_cell, column(:symbol)).map(&:text).index(symbol)
unit = @user.units.find_sole_by(symbol: symbol)
link.click
assert_selector '.flash.notice'
if unit.base_id?
find_link(unit.base.symbol).ancestor('tr').click_on(t('units.unit.new_subunit'))
fill_in 'unit[multiplier]', with: unit.multiplier
find(:table_cell, column(:multiplier), fillable: true)
.fill_in with: unit.multiplier
else
click_on t('units.index.new_unit')
end
fill_in 'unit[symbol]', with: unit.symbol
click_on t('helpers.submit.create')
find(:table_cell, column(:symbol), fillable: true).fill_in with: unit.symbol
click_on t(:save)
within "tbody > tr:nth-child(#{index+1})" do
assert_selector :link, exact_text: symbol
within :table_row, index+1 do
assert_selector :table_cell, column(:symbol), exact_text: symbol
end
end
@@ -183,12 +190,14 @@ class UnitsTest < ApplicationSystemTestCase
else
assert_matches_selector link, :link, disabled: true
end
click_on t(:cancel)
click_on t(:discard)
assert_no_selector '.flash.alert'
# TODO: change to assert_selector :table, rows: ...
assert_equal symbols, list_symbols
refresh
assert_no_selector '.flash.alert'
# TODO: change to assert_selector :table, rows: ...
assert_equal symbols, list_symbols
end
@@ -235,11 +244,11 @@ class UnitsTest < ApplicationSystemTestCase
end
# NOTE: extend with any add/edit link
test "new form closes on escape key" do
test "new and edit forms close on escape key" do
sign_in
click_on t('units.index.new_unit')
first('tbody > tr').all(:field).sample.send_keys :escape
within 'tbody' do
first(:table_row).all(:field).sample.send_keys :escape
within :table do
assert_no_selector :fillable_field
end
end
@@ -249,13 +258,27 @@ class UnitsTest < ApplicationSystemTestCase
sign_in
click_on t('units.index.new_unit')
within 'tbody' do
find(:link_or_button, exact_text: t(:cancel)).click
find(:link_or_button, exact_text: t(:discard)).click
assert_no_selector :fillable_field
end
click_on t('units.index.new_unit')
assert_selector 'tbody > tr:has(input, textarea)'
end
test "new and edit forms open without changing table columns width" do
sign_in
width = if has_selector?(:table_row)
-> { all(:table_row).sample.all(:table_cell).map { |c| c.rect.width } }
else
link_labels.slice!(:new_unit)
-> { find(:table).rect.width }
end
assert_no_changes width do
all(:link, exact_text: link_labels.values.sample).sample.click
assert_selector :table_cell, fillable: true
end
end
test "rebase" do
# TODO
assert true
@@ -272,8 +295,8 @@ class UnitsTest < ApplicationSystemTestCase
end
end
assert_selector :table, rows: [[t('units.no_items')]]
assert_selector :table, caption: t('units.index.no_items'), rows: []
refresh
assert_selector :table, rows: [[t('units.no_items')]]
assert_selector :table, caption: t('units.index.no_items'), rows: []
end
end