Compare commits
7 Commits
4d86652247
...
4d39b6075c
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d39b6075c | |||
| 6e0148ecdc | |||
| 363571a336 | |||
| 31db40fae8 | |||
| 9c51887024 | |||
| 2c845f3528 | |||
| f1578fad75 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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(' ') %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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,2 +1,2 @@
|
||||
<%= turbo_stream.close_form row %>
|
||||
<%= turbo_stream.close_form id %>
|
||||
<%= turbo_stream.update :flashes %>
|
||||
|
||||
@@ -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">⠿</td>
|
||||
|
||||
<td class="handle" draggable="true">⠿
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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 -%>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,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,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 %>
|
||||
|
||||
@@ -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,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 %>
|
||||
|
||||
@@ -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,2 +1,2 @@
|
||||
<%= turbo_stream.close_form row %>
|
||||
<%= turbo_stream.close_form id %>
|
||||
<%= turbo_stream.update :flashes %>
|
||||
|
||||
@@ -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">⠿</td>
|
||||
<td class="handle" draggable="true">⠿
|
||||
<% else %>
|
||||
<td></td>
|
||||
<td>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -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,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? %>
|
||||
|
||||
@@ -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 -%>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user