recourse 3.0.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +234 -0
- data/README.md +150 -20
- data/app/controllers/bookmarks_controller.rb +7 -0
- data/app/controllers/concerns/recourse/landing.rb +49 -0
- data/app/controllers/concerns/recourse/list_resolution.rb +28 -0
- data/app/controllers/concerns/recourse/paging.rb +17 -0
- data/app/controllers/concerns/recourse/parent_naming.rb +24 -0
- data/app/controllers/concerns/recourse/parent_resolution.rb +63 -0
- data/app/controllers/concerns/recourse/reference_resolution.rb +70 -0
- data/app/controllers/concerns/recourse/resource_resolution.rb +68 -0
- data/app/controllers/concerns/recourse/zoning.rb +29 -0
- data/app/controllers/recourse/base_controller.rb +85 -0
- data/app/controllers/recourse/bookmarks_controller.rb +76 -0
- data/app/controllers/recourses_controller.rb +6 -25
- data/app/javascript/recourse/bookmark_controller.js +100 -0
- data/app/javascript/recourse/clear_controller.js +22 -0
- data/app/javascript/recourse/combobox_controller.js +68 -0
- data/app/javascript/recourse/confirm.js +46 -0
- data/app/javascript/recourse/deselect_controller.js +37 -0
- data/app/javascript/recourse/flash.js +35 -0
- data/app/javascript/recourse/limit_controller.js +23 -0
- data/app/javascript/recourse/phone_controller.js +33 -0
- data/app/javascript/recourse/relative_time_controller.js +45 -0
- data/app/javascript/recourse/reveal_controller.js +16 -0
- data/app/javascript/recourse/scheme_controller.js +51 -0
- data/app/javascript/recourse/search_controller.js +78 -0
- data/app/javascript/recourse/shortcuts_controller.js +42 -0
- data/app/javascript/recourse/timezone_controller.js +27 -0
- data/app/javascript/recourse/toast_controller.js +35 -0
- data/app/javascript/recourse/tooltip_controller.js +16 -0
- data/app/javascript/recourse/written.js +29 -0
- data/app/javascript/recourse/written_controller.js +28 -0
- data/app/views/layouts/recourses.html.erb +442 -0
- data/app/views/recourses/_breadcrumb.html.erb +35 -0
- data/app/views/recourses/_card.html.erb +33 -0
- data/app/views/recourses/_color.html.erb +20 -0
- data/app/views/recourses/_combobox.html.erb +68 -0
- data/app/views/recourses/_confirm.html.erb +14 -0
- data/app/views/recourses/_fields.html.erb +3 -0
- data/app/views/recourses/_flash.html.erb +26 -0
- data/app/views/recourses/_footer.html.erb +22 -0
- data/app/views/recourses/_form.html.erb +11 -0
- data/app/views/recourses/_none.html.erb +1 -0
- data/app/views/recourses/_results.html.erb +10 -0
- data/app/views/recourses/_row.html.erb +4 -10
- data/app/views/recourses/_scheme.html.erb +14 -0
- data/app/views/recourses/_search.html.erb +24 -0
- data/app/views/recourses/_sidebar.html.erb +40 -0
- data/app/views/recourses/_table.html.erb +47 -42
- data/app/views/recourses/_values.html.erb +3 -0
- data/app/views/recourses/edit.html.erb +10 -0
- data/app/views/recourses/index.html.erb +27 -24
- data/app/views/recourses/new.html.erb +3 -0
- data/app/views/recourses/show.html.erb +10 -0
- data/config/locales/recourse.en.yml +72 -0
- data/lib/recourse/bookmarks.rb +51 -0
- data/lib/recourse/broadcasting.rb +33 -0
- data/lib/recourse/colors.rb +32 -0
- data/lib/recourse/columns.rb +80 -0
- data/lib/recourse/controllers.rb +26 -0
- data/lib/recourse/engine.rb +33 -13
- data/lib/recourse/helpers/actions.rb +56 -0
- data/lib/recourse/helpers/bookmarks.rb +88 -0
- data/lib/recourse/helpers/buttons.rb +65 -0
- data/lib/recourse/helpers/cards.rb +67 -0
- data/lib/recourse/helpers/cells.rb +89 -0
- data/lib/recourse/helpers/choices.rb +49 -0
- data/lib/recourse/helpers/colors.rb +20 -0
- data/lib/recourse/helpers/comboboxes.rb +87 -0
- data/lib/recourse/helpers/constraints.rb +92 -0
- data/lib/recourse/helpers/counters.rb +75 -0
- data/lib/recourse/helpers/deletions.rb +87 -0
- data/lib/recourse/helpers/details.rb +41 -0
- data/lib/recourse/helpers/examples.rb +35 -0
- data/lib/recourse/helpers/fields.rb +78 -0
- data/lib/recourse/helpers/filters.rb +91 -0
- data/lib/recourse/helpers/formats.rb +94 -0
- data/lib/recourse/helpers/inputs.rb +92 -0
- data/lib/recourse/helpers/kinds.rb +87 -0
- data/lib/recourse/helpers/limits.rb +37 -0
- data/lib/recourse/helpers/names.rb +44 -0
- data/lib/recourse/helpers/navigation.rb +75 -0
- data/lib/recourse/helpers/parents.rb +82 -0
- data/lib/recourse/helpers/pictures.rb +33 -0
- data/lib/recourse/helpers/references.rb +99 -0
- data/lib/recourse/helpers/refreshes.rb +27 -0
- data/lib/recourse/helpers/resources.rb +47 -0
- data/lib/recourse/helpers/routing.rb +28 -0
- data/lib/recourse/helpers/rows.rb +33 -0
- data/lib/recourse/helpers/schemes.rb +14 -0
- data/lib/recourse/helpers/searches.rb +57 -0
- data/lib/recourse/helpers/shortcuts.rb +30 -0
- data/lib/recourse/helpers/sidebars.rb +39 -0
- data/lib/recourse/helpers/sorts.rb +62 -0
- data/lib/recourse/helpers/tabs.rb +82 -0
- data/lib/recourse/helpers/times.rb +43 -0
- data/lib/recourse/helpers/values.rb +64 -0
- data/lib/recourse/helpers/zones.rb +38 -0
- data/lib/recourse/helpers.rb +81 -0
- data/lib/recourse/icons.rb +21 -0
- data/lib/recourse/limits.rb +12 -0
- data/lib/recourse/recoursive.rb +66 -19
- data/lib/recourse/registry.rb +36 -0
- data/lib/recourse/routes/nested.rb +36 -0
- data/lib/recourse/routes.rb +57 -0
- data/lib/recourse/routing.rb +11 -32
- data/lib/recourse/schemes.rb +6 -0
- data/lib/recourse/scopes.rb +37 -0
- data/lib/recourse/search.rb +65 -0
- data/lib/recourse/searchable/columns.rb +99 -0
- data/lib/recourse/searchable/filters.rb +58 -0
- data/lib/recourse/searchable/terms.rb +56 -0
- data/lib/recourse/searchable.rb +47 -22
- data/lib/recourse/titles.rb +56 -0
- data/lib/recourse/version.rb +2 -1
- data/lib/recourse/writes.rb +12 -0
- data/lib/recourse/zones.rb +6 -0
- data/lib/recourse.rb +79 -7
- data/vendor/recourse/bootstrap-icons.min.css +5 -0
- data/vendor/recourse/bootstrap.bundle.min.js +9 -0
- data/vendor/recourse/bootstrap.min.css +2 -0
- data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
- data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
- data/vendor/recourse/stimulus.js +2563 -0
- metadata +169 -30
- data/Rakefile +0 -6
- data/app/helpers/navigable_helper.rb +0 -35
- data/app/helpers/recoursive_helper.rb +0 -10
- data/app/helpers/searchable_helper.rb +0 -54
- data/db/migrate/20260323234318_add_baby_to_posts.rb +0 -5
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# A counter cache's column in a table: an icon heading over bare figures, or —
|
|
4
|
+
# where the table is wide enough to read them — the counted model's own words.
|
|
5
|
+
module Counters
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# The class a counter's cells carry, which is what sizes the column like the
|
|
9
|
+
# action columns beside it rather than like the columns carrying text.
|
|
10
|
+
def counter_class(column)
|
|
11
|
+
'recourse-counter' if resource_model.recourse_counters.key? column
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# A figure, and the word saying what it counts. The `aria-label` carries both,
|
|
15
|
+
# since a link whose whole text is a number announces as `10` and nothing else.
|
|
16
|
+
# A span where there is no index to link to, so an unlinked count is named the
|
|
17
|
+
# same as a linked one.
|
|
18
|
+
def counter_cell(resource, value, association)
|
|
19
|
+
# Delimited like every other count on the page: the filter menu beside the
|
|
20
|
+
# table already reads 38,405, and one figure in two spellings reads as two.
|
|
21
|
+
count = number_with_delimiter value
|
|
22
|
+
counted = counter_counted count, value, association
|
|
23
|
+
named = counter_naming count, association
|
|
24
|
+
path = resource_controller_path
|
|
25
|
+
nested = nested_path_of path, association
|
|
26
|
+
return tag.span(counted, **named) unless nested && routed?(nested, 'index')
|
|
27
|
+
|
|
28
|
+
turbo_link_to counted, nested_url(resource, path, nested), **named
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The two forms of one count, of which a stylesheet ever shows one: the bare
|
|
32
|
+
# figure where the column is a square, and the figure with the counted model's
|
|
33
|
+
# own word where the table is wide enough to read it. `count:` is what makes it
|
|
34
|
+
# `1 place` rather than `1 places`, and `lower:` what leaves it reading as a
|
|
35
|
+
# phrase. Written out twice rather than as a figure and a suffix, so that each
|
|
36
|
+
# is a whole thing to show or hide — which is what lets the tooltip ride on the
|
|
37
|
+
# first of them and keep quiet beside the second.
|
|
38
|
+
def counter_counted(count, value, association)
|
|
39
|
+
title = Recourse.model_title association.klass
|
|
40
|
+
word = Recourse.model_title association.klass, count: value, lower: true
|
|
41
|
+
|
|
42
|
+
safe_join [
|
|
43
|
+
tag.span(count, class: 'recourse-counter-figure', data: tooltip_on_top(title)),
|
|
44
|
+
tag.span("#{count} #{word}", class: 'recourse-counter-word'),
|
|
45
|
+
]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The label reads the figure and the word, which is the order somebody hearing it
|
|
49
|
+
# needs them in — and it is on the link either way, since nothing is hidden from
|
|
50
|
+
# a screen reader by a width.
|
|
51
|
+
def counter_naming(count, association)
|
|
52
|
+
{ aria: { label: "#{count} #{Recourse.model_title association.klass}" } }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Where the counted rows were nested under this resource, read off the routes
|
|
56
|
+
# rather than joined onto the parent's path: a `namespace` between the two is
|
|
57
|
+
# part of the address and nothing here would know to put it back.
|
|
58
|
+
def nested_path_of(path, association)
|
|
59
|
+
Recourse.nested_under(path).find { |one| one.split('/').last == association.name.to_s }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The icon the sidebar and the breadcrumb already draw for the counted model,
|
|
63
|
+
# speaking the heading's word to a screen reader — and that word itself, for the
|
|
64
|
+
# table with room to read it. Exactly one of the two is ever shown, and the icon's
|
|
65
|
+
# tooltip leaves with the icon: what cannot be hovered repeats nothing.
|
|
66
|
+
def counter_title(association)
|
|
67
|
+
title = Recourse.model_title association.klass
|
|
68
|
+
icon = icon_heading association.klass.recourse_icon, title,
|
|
69
|
+
class: 'recourse-counter-icon'
|
|
70
|
+
|
|
71
|
+
safe_join [icon, tag.span(title, class: 'recourse-counter-word')]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# The button that deletes the record a form is showing, and the warning it puts
|
|
4
|
+
# in front of whoever clicked it.
|
|
5
|
+
module Deletions
|
|
6
|
+
# `dependent:` values that take the children with the parent.
|
|
7
|
+
DESTROYED = %i[destroy destroy_async].freeze
|
|
8
|
+
|
|
9
|
+
# And the one that keeps them, holding the key open. Anything else — a bare
|
|
10
|
+
# `has_many`, a `:restrict` — is left unsaid rather than guessed at.
|
|
11
|
+
NULLIFIED = %i[nullify].freeze
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
# Deletes the record on the page, or nothing at all where no action is routed
|
|
16
|
+
# to delete it with — the same two guards the edit link answers to.
|
|
17
|
+
def destroy_resource_button(record)
|
|
18
|
+
path = destroy_resource_path record
|
|
19
|
+
return unless path
|
|
20
|
+
|
|
21
|
+
options = {
|
|
22
|
+
method: :delete, class: 'btn btn-sm btn-solid theme-danger ms-3',
|
|
23
|
+
form_class: 'd-inline-block', data: { turbo_confirm: destroy_warning(record) },
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
button_to t('recourse.delete', model: resource_name), path, **options
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# What deleting this record takes with it, counted a level down and no further:
|
|
30
|
+
# a state reaches counties, then ZIPs, then locations, and counting that far
|
|
31
|
+
# would join 40,965 rows to draw one page.
|
|
32
|
+
def destroy_warning(record)
|
|
33
|
+
lines = [t('recourse.deletion.title', record: destroy_title(record)), nil]
|
|
34
|
+
|
|
35
|
+
[*lines, *dependent_lines(record), nil, t('recourse.deletion.undone')].join "\n"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def destroy_resource_path(record)
|
|
39
|
+
return unless routed_action? 'destroy'
|
|
40
|
+
|
|
41
|
+
url_for action: :destroy, id: record
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The middle of the warning, in the order it reads: what goes, what stays, and
|
|
45
|
+
# only then what is under what goes — the levels this stops short of counting.
|
|
46
|
+
def dependent_lines(record)
|
|
47
|
+
going = dependents record, DESTROYED
|
|
48
|
+
staying = dependents record, NULLIFIED
|
|
49
|
+
lines = []
|
|
50
|
+
lines << t('recourse.deletion.going', list: going.to_sentence) if going.any?
|
|
51
|
+
lines << staying_line(staying) if staying.any?
|
|
52
|
+
lines << t('recourse.deletion.under') if going.any?
|
|
53
|
+
|
|
54
|
+
lines
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def staying_line(staying)
|
|
58
|
+
t 'recourse.deletion.staying', list: staying.to_sentence, model: resource_name
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def dependents(record, kinds)
|
|
62
|
+
record.class.reflect_on_all_associations(:has_many).filter_map do |association|
|
|
63
|
+
next if association.through_reflection || kinds.exclude?(association.options[:dependent])
|
|
64
|
+
|
|
65
|
+
dependent_count record, association
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# `association.reader` rather than a method named at runtime, and `count` rather
|
|
70
|
+
# than loading them: the warning needs how many, never which.
|
|
71
|
+
def dependent_count(record, association)
|
|
72
|
+
count = record.association(association.name).reader.count
|
|
73
|
+
return if count.zero?
|
|
74
|
+
|
|
75
|
+
name = Recourse.downcase association.klass.model_name.human
|
|
76
|
+
|
|
77
|
+
"#{number_with_delimiter count} #{name.pluralize count}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# What the record is called, or what it is, for one that answers to no label.
|
|
81
|
+
def destroy_title(record)
|
|
82
|
+
record.attributes[record.class.recourse_label.to_s].presence ||
|
|
83
|
+
record.class.model_name.human
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# What a value is drawn behind when it is worth more room than a page will give it
|
|
4
|
+
# unasked: closed to begin with, so a column of values stays a column of values.
|
|
5
|
+
module Details
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# The shape itself, said once: what the thing is, and the thing under it. A
|
|
9
|
+
# picture and a list are both this, and neither should be spelling it out.
|
|
10
|
+
def detailed(summary, body)
|
|
11
|
+
tag.details { safe_join [tag.summary(summary), body] }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# A list reads as how many first — the values are what opening it is for — and
|
|
15
|
+
# then as the values themselves. Nil rather than an empty `<details>` where there
|
|
16
|
+
# is nothing in it, so an empty list reads as the dash every other empty value
|
|
17
|
+
# reads as rather than as a summary with nothing behind it.
|
|
18
|
+
def listed(values)
|
|
19
|
+
count = listed_count values
|
|
20
|
+
return unless count
|
|
21
|
+
|
|
22
|
+
detailed count, listed_items(values)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# How many a list holds, and nothing else: what a table shows in place of the
|
|
26
|
+
# values, and what the summary above reads before anybody opens it. Nil for an
|
|
27
|
+
# empty one, which reads as blank in a cell and as the dash on a record's page.
|
|
28
|
+
def listed_count(values)
|
|
29
|
+
return if values.blank?
|
|
30
|
+
|
|
31
|
+
t 'recourse.items', count: values.size
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# `mb-0`, because the row below it draws the rule between them and a list's own
|
|
35
|
+
# bottom margin would push that rule away from the values it closes.
|
|
36
|
+
def listed_items(values)
|
|
37
|
+
tag.ul safe_join(values.map { |one| tag.li one }), class: 'mb-0 mt-2'
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# Reads an example of an accepted value off the pattern that accepts it.
|
|
4
|
+
module Examples
|
|
5
|
+
# One token of a pattern and the `{n}` that may repeat it, so `\d{5}` reads
|
|
6
|
+
# as a single digit five times over rather than as five separate characters.
|
|
7
|
+
# `\d`, a bracket class and a literal are all it knows; nothing else is used.
|
|
8
|
+
PATTERN_TOKENS = /(\\d|\[[^\]]*\]|[^{])(?:\{(\d+),?\d*\})?/
|
|
9
|
+
|
|
10
|
+
# A token that is a bracket class, so `[2-9]` counts as one rather than five.
|
|
11
|
+
CLASS_TOKEN = /\A\[/
|
|
12
|
+
|
|
13
|
+
# First character a bracket class offers, past the bracket and any negation.
|
|
14
|
+
CLASS_SAMPLE = /[^\[^]/
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# What the pattern accepts, so a field can name the shape it wants instead of
|
|
19
|
+
# only reporting that what was typed is wrong.
|
|
20
|
+
def pattern_example(pattern)
|
|
21
|
+
tokens = pattern.scan PATTERN_TOKENS
|
|
22
|
+
|
|
23
|
+
tokens.map { |token, count| pattern_sample(token) * (count || 1).to_i }.join
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def pattern_sample(token)
|
|
27
|
+
case token
|
|
28
|
+
when '\d' then '0'
|
|
29
|
+
when CLASS_TOKEN then token[CLASS_SAMPLE]
|
|
30
|
+
else token
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# Chooses the form field a column deserves, and labels it.
|
|
4
|
+
module Fields
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
# One labelled field in the form's grid. `label:` overrides the heading and
|
|
8
|
+
# `type:` overrides the input the column would otherwise have chosen.
|
|
9
|
+
def field(name, **options)
|
|
10
|
+
column = name.to_s
|
|
11
|
+
label = options.fetch :label, reference_title(column, belongs_to_association(column))
|
|
12
|
+
|
|
13
|
+
tag.div class: ROW do
|
|
14
|
+
safe_join [
|
|
15
|
+
@recourse_form.label(column, label, class: 'form-label'),
|
|
16
|
+
resource_field(@recourse_form, column, type: options[:type]),
|
|
17
|
+
field_comment(column),
|
|
18
|
+
].compact
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# What the database says the column is for, under the field that sets it.
|
|
23
|
+
def field_comment(column)
|
|
24
|
+
field_note resource_model.recourse_comment(column), field_note_id(column)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# What the control points at, where it should point at anything. Only where there
|
|
28
|
+
# is a note to point at — and only where the field has nothing more urgent to say,
|
|
29
|
+
# since an error outranks a hint. `field_error_proc` is the host's and writes its
|
|
30
|
+
# own `aria-describedby` for an invalid field, so rather than leave a second one
|
|
31
|
+
# for the browser to throw away, this one stands down and says so here.
|
|
32
|
+
def field_described(column)
|
|
33
|
+
return if resource_model.recourse_comment(column).blank?
|
|
34
|
+
return if errors_on(column).any?
|
|
35
|
+
|
|
36
|
+
field_note_id column
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Named off the field's own id, the way Rails names everything else about it.
|
|
40
|
+
def field_note_id(column)
|
|
41
|
+
id = @recourse_form.field_id column
|
|
42
|
+
|
|
43
|
+
"#{id}_help"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The line under a field saying what somebody wants to know before filling it in:
|
|
47
|
+
# what the column is for. Nothing at all where there is nothing to say.
|
|
48
|
+
#
|
|
49
|
+
# `mt-1` because Bootstrap's `.form-text` declares `--bs-form-text-margin-top` and
|
|
50
|
+
# never applies it; `.25rem` is what that variable holds, so this is the gap the
|
|
51
|
+
# class already meant. `fg-secondary` for a line that answers a question nobody
|
|
52
|
+
# asked — quieter than the value it sits under, and quieter than `.form-text`'s
|
|
53
|
+
# own `--bs-fg-2`, which a utility later in the cascade is what overrides.
|
|
54
|
+
def field_note(text, id)
|
|
55
|
+
tag.div text, class: 'form-text mt-1 fg-secondary', id: id if text.present?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A field typed by what the column holds, not merely a text box.
|
|
59
|
+
def resource_field(form, column, type: nil)
|
|
60
|
+
association = belongs_to_association column
|
|
61
|
+
return reference_field form, column, association if association
|
|
62
|
+
|
|
63
|
+
# Rails mirrors `maxlength` into `size`, which would shrink the box to it.
|
|
64
|
+
aria = { describedby: field_described(column) }
|
|
65
|
+
options = { class: 'form-control', size: nil, aria: }.merge field_html(column, type)
|
|
66
|
+
|
|
67
|
+
return form.text_field column, **options, type: type if type
|
|
68
|
+
return form.email_field column, **options if column == 'email'
|
|
69
|
+
|
|
70
|
+
kind_field form, column, **options
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def encrypted_column?(column)
|
|
74
|
+
resource_model.recourse_encrypted_names.include? column
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# The menus beside a search box: one per foreign key a table can be narrowed by,
|
|
4
|
+
# and one per column whose values are a known few.
|
|
5
|
+
module Filters
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# The filters this page draws, as the markup each one is. A declared filter
|
|
9
|
+
# that draws nothing falls out here — a foreign key whose label is typed
|
|
10
|
+
# rather than picked — so what is left is what a form would hold, which is
|
|
11
|
+
# what decides whether there is a form at all.
|
|
12
|
+
def resource_filter_fields
|
|
13
|
+
resource_filters.filter_map { |predicate, options| filter_field predicate, **options }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The model's filters, less the one a nested route already answered:
|
|
17
|
+
# /markets/1/sectors is filtered by market_id in the URL itself, and a menu
|
|
18
|
+
# for it would only offer to re-ask — or to contradict — the address.
|
|
19
|
+
def resource_filters
|
|
20
|
+
parent = resource_parent_association
|
|
21
|
+
filters = resource_model.filter_fields
|
|
22
|
+
return filters unless parent
|
|
23
|
+
|
|
24
|
+
filters.except "#{parent.foreign_key}_in"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# One filter: the values a column of its own admits, or a menu of the records a
|
|
28
|
+
# foreign key points at, holding whichever the request already asked for.
|
|
29
|
+
def filter_field(predicate, label: nil, scope: nil)
|
|
30
|
+
column = predicate.to_s.sub Search::LIST_PREDICATES, ''
|
|
31
|
+
|
|
32
|
+
choice_filter(predicate, column, label) || reference_filter(predicate, column, label, scope)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A menu of the records a foreign key points at. Nothing where that key is typed
|
|
36
|
+
# rather than picked, which is the same question the field beside it asks: the
|
|
37
|
+
# label is bounded, or the table is too long to list. Either way the menu would
|
|
38
|
+
# be a table of its own. A `scope:` draws one anyway.
|
|
39
|
+
def reference_filter(predicate, column, label, scope)
|
|
40
|
+
association = belongs_to_association column
|
|
41
|
+
return if association.nil? ||
|
|
42
|
+
(scope.nil? && association.klass.recourse_typed_reference?)
|
|
43
|
+
|
|
44
|
+
filter_combobox predicate, label || reference_title(column, association),
|
|
45
|
+
(scope || association.klass).all
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def filter_combobox(predicate, title, recourses)
|
|
49
|
+
label = recourses.klass.recourse_label
|
|
50
|
+
counter = filter_counter recourses.klass
|
|
51
|
+
# What it reads as when nothing is ticked, so the way back is a line in the menu.
|
|
52
|
+
models = Recourse.model_title recourses.klass, lower: true
|
|
53
|
+
|
|
54
|
+
filter_menu predicate, title, nil, t('recourse.all', models: models),
|
|
55
|
+
label: label.to_s, counter: counter,
|
|
56
|
+
recourses: filter_options(recourses, label, counter)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The commonest choice first where the model keeps a count, since a menu is read
|
|
60
|
+
# from the top and most requests want the option most rows are behind — and by
|
|
61
|
+
# name where it keeps none. The name breaks ties, or two markets on the same
|
|
62
|
+
# number would swap places between one request and the next.
|
|
63
|
+
def filter_options(recourses, label, counter)
|
|
64
|
+
return recourses.select(:id, label).order label unless counter
|
|
65
|
+
|
|
66
|
+
recourses.select(:id, label, counter).order counter => :desc, label => :asc
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The column on the model a filter lists that counts the rows being filtered —
|
|
70
|
+
# `markets.zips_count` on `/zips`. Read from the counter caches that model keeps
|
|
71
|
+
# rather than from a column named after this one, so a `zips_count` nobody
|
|
72
|
+
# maintains is not a count.
|
|
73
|
+
def filter_counter(klass)
|
|
74
|
+
klass.recourse_counters.find { |_, one| one.klass == resource_model }&.first
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Never invalid and never required: a filter narrows rather than sets.
|
|
78
|
+
def filter_menu(predicate, title, values, all, **)
|
|
79
|
+
render('recourses/combobox', name: "q[#{predicate}]", id: "q_#{predicate}",
|
|
80
|
+
placeholder: title, multiple: true, aria_label: title,
|
|
81
|
+
selected: filter_values(predicate), small: true,
|
|
82
|
+
all: all, values: Array(values), **)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A multiple combobox submits one comma-joined input, read back out the same way.
|
|
86
|
+
def filter_values(predicate)
|
|
87
|
+
query_params[predicate].to_s.split ','
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# How one attribute reads on a page that only reads it.
|
|
4
|
+
module Formats
|
|
5
|
+
# One absolute web address and nothing else: a value to follow, not to read.
|
|
6
|
+
# Anything around it — words, a second address — reads as text instead. What it
|
|
7
|
+
# captures is what a link says: the host, less any `www.`, and whatever follows.
|
|
8
|
+
WEB_URL = %r{\Ahttps?://(?:www\.)?([^/?#\s]+)(\S*)\z}
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
# What the record says for one column, formatted by what the column holds.
|
|
13
|
+
def formatted_value(column)
|
|
14
|
+
association = belongs_to_association column
|
|
15
|
+
return named_cell resource_record, association if association
|
|
16
|
+
|
|
17
|
+
formatted_attribute column, resource_record.attributes[column]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# One value, formatted by the kind its column holds — the one ladder a table
|
|
21
|
+
# cell and a show page's value both come down, so a boolean is the same icon
|
|
22
|
+
# and an enum the same badge on either. A block, where the caller has one,
|
|
23
|
+
# marks the search terms inside whichever arm ends up as words.
|
|
24
|
+
def formatted_attribute(column, value, &)
|
|
25
|
+
kind = attribute_kind column
|
|
26
|
+
return listed Array(value) if kind == :list
|
|
27
|
+
return formatted_number kind, column, value if numeric_kind? kind
|
|
28
|
+
|
|
29
|
+
formatted_text kind, value, &
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def formatted_number(kind, column, value)
|
|
33
|
+
return uncounted kind, value if Kinds::UNCOUNTED_KINDS.include? kind
|
|
34
|
+
|
|
35
|
+
case kind
|
|
36
|
+
when :integer then number_with_delimiter value
|
|
37
|
+
when :phone then number_to_phone value
|
|
38
|
+
when :monetary then number_to_currency value, **precision_option(column)
|
|
39
|
+
when :percentage then number_to_percentage value, **precision_option(column)
|
|
40
|
+
when :decimal then number_with_precision value, **precision_option(column)
|
|
41
|
+
else number_with_precision value
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def formatted_text(kind, value, &)
|
|
46
|
+
case kind
|
|
47
|
+
when :enum then value && enum_badge(marked(value, &))
|
|
48
|
+
when :date, :datetime, :time then value && localized(kind, value)
|
|
49
|
+
when *Kinds::JSON_KINDS then value.presence && json_block(value)
|
|
50
|
+
else linked_or_marked(value, &)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# One whole web address is a value to follow rather than to read, and anything
|
|
55
|
+
# else is words — which the caller may have marking of its own for.
|
|
56
|
+
def linked_or_marked(value, &)
|
|
57
|
+
web_url?(value) ? url_link(value) : marked(value, &)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The caller's own marking, where it has one: a table marks what a search
|
|
61
|
+
# matched, and a show page, which no search reached, has nothing to mark.
|
|
62
|
+
def marked(value, &)
|
|
63
|
+
block_given? ? yield(value) : value
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A payload read as the JSON it is rather than as the Hash Ruby prints. In a block
|
|
67
|
+
# of its own that scrolls rather than grows: one of these is as tall as a page, and
|
|
68
|
+
# nothing else on a record's page should have to move aside for it. `presence`, so
|
|
69
|
+
# an empty payload reads as the dash every other empty value does.
|
|
70
|
+
def json_block(value)
|
|
71
|
+
tag.pre JSON.pretty_generate(value), class: 'recourse-payload'
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def enum_badge(value)
|
|
75
|
+
tag.span value, class: 'badge'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def web_url?(value)
|
|
79
|
+
value.is_a?(String) && value.match?(WEB_URL)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Bootstrap's icon link, saying the value leads somewhere the way text cannot.
|
|
83
|
+
# What it reads is the host, and an ellipsis where the address goes further --
|
|
84
|
+
# a path is how a machine finds the page, and the href is already carrying it.
|
|
85
|
+
def url_link(value)
|
|
86
|
+
host, rest = value.match(WEB_URL).captures
|
|
87
|
+
said = rest.delete_suffix('/').empty? ? host : "#{host}/…"
|
|
88
|
+
|
|
89
|
+
tag.a safe_join([said, icon_tag(:point_right)], ' '),
|
|
90
|
+
href: value, class: 'icon-link icon-link-hover'
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# The control an attribute is typed into, chosen by what the attribute holds.
|
|
4
|
+
module Inputs
|
|
5
|
+
# Numbers a field takes whole. A month and a year are counted in no smaller unit
|
|
6
|
+
# than themselves, so neither admits the decimals a `step` would otherwise allow.
|
|
7
|
+
WHOLE_KINDS = %i[integer month year].freeze
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# A field for a column whose kind is what decides it, the fallback being a text
|
|
12
|
+
# box for anything that never said what it holds. The checkbox is handed one
|
|
13
|
+
# option by name rather than the whole hash: the rest is `maxlength`, `pattern`,
|
|
14
|
+
# `placeholder`, `inputmode` and `required`, none of which a box that is either
|
|
15
|
+
# ticked or not has any use for.
|
|
16
|
+
def kind_field(form, column, **options)
|
|
17
|
+
kind = attribute_kind column
|
|
18
|
+
return numeric_field(form, column, kind, **options) if numeric_kind? kind
|
|
19
|
+
return menu_field form, column, kind if menu_kind? kind
|
|
20
|
+
|
|
21
|
+
case kind
|
|
22
|
+
when :boolean then tag.div form.check_box(column, class: 'check', aria: options[:aria])
|
|
23
|
+
when :date then form.date_field(column, **options)
|
|
24
|
+
when :datetime then form.datetime_local_field(column, **options)
|
|
25
|
+
when :text, :list then area_field(form, column, kind, **options)
|
|
26
|
+
else form.text_field(column, **options)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A list is typed one value to a line, a newline being the one separator a value
|
|
31
|
+
# cannot itself contain. Handed its value, since a box left to read an Array off
|
|
32
|
+
# the record would print the brackets.
|
|
33
|
+
def area_field(form, column, kind, **)
|
|
34
|
+
return form.text_area(column, **, rows: 1) if kind == :text
|
|
35
|
+
|
|
36
|
+
values = Array form.object&.attributes&.fetch(column, nil)
|
|
37
|
+
form.text_area column, **, rows: 3, value: values.join("\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# `step` is what limits a field to whole numbers, and what admits the decimals a
|
|
41
|
+
# column keeps: two of them for a `scale: 2`, any number at all for a float,
|
|
42
|
+
# which says how precise it is nowhere.
|
|
43
|
+
def numeric_field(form, column, kind, **)
|
|
44
|
+
return form.telephone_field(column, **) if kind == :phone
|
|
45
|
+
return adorned_field(form, column, **) if %i[monetary percentage].include? kind
|
|
46
|
+
|
|
47
|
+
form.number_field(column, **, **step_options(column, kind))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def step_options(column, kind)
|
|
51
|
+
return { step: 1 } if WHOLE_KINDS.include? kind
|
|
52
|
+
|
|
53
|
+
scale = attribute_scale column
|
|
54
|
+
return { step: :any } unless scale
|
|
55
|
+
|
|
56
|
+
{ step: 10.0**-scale, max: digit_ceiling(column, scale) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Bootstrap adorns a control by wrapping it: the border and the padding are the
|
|
60
|
+
# wrapper's, the input inside is a `.form-ghost` with neither, and
|
|
61
|
+
# `.form-adorn-end` is what moves the unit to the far side of it.
|
|
62
|
+
def adorned_field(form, column, **options)
|
|
63
|
+
end_ = attribute_kind(column) == :percentage
|
|
64
|
+
classes = ['form-control form-adorn d-flex', ('form-adorn-end' if end_)].compact
|
|
65
|
+
unit = tag.span adorn_unit(end_), class: 'form-adorn-text'
|
|
66
|
+
|
|
67
|
+
tag.div class: classes.join(' ') do
|
|
68
|
+
safe_join [unit, adorned_input(form, column, **options)]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def adorned_input(form, column, **)
|
|
73
|
+
form.number_field(column, **, class: 'form-ghost',
|
|
74
|
+
**step_options(column, attribute_type(column)))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The currency the app counts in, which is what its number formats already say.
|
|
78
|
+
def adorn_unit(percentage)
|
|
79
|
+
percentage ? '%' : I18n.t('number.currency.format.unit', default: '$')
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The largest value the column has room for: `precision: 4, scale: 2` keeps four
|
|
83
|
+
# digits of which two are decimals, so 99.99 and nothing above it.
|
|
84
|
+
def digit_ceiling(column, scale)
|
|
85
|
+
precision = attribute_precision column
|
|
86
|
+
return unless precision
|
|
87
|
+
|
|
88
|
+
(10.0**(precision - scale)) - (10.0**-scale)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|