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,87 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# What an attribute holds, which is the one question a value and a field are both
|
|
4
|
+
# answers to.
|
|
5
|
+
module Kinds
|
|
6
|
+
# Numbers, which differ by what they are of rather than by how they are stored.
|
|
7
|
+
NUMERIC_KINDS = %i[integer decimal float phone monetary percentage month year].freeze
|
|
8
|
+
|
|
9
|
+
# The two of those that count nothing, and so are neither delimited nor rounded:
|
|
10
|
+
# a month is a word for one and a year is when something happened.
|
|
11
|
+
UNCOUNTED_KINDS = %i[month year].freeze
|
|
12
|
+
|
|
13
|
+
# The kinds whose values are a list known before anybody types: an enum's are the
|
|
14
|
+
# model's own, and a time zone's are Rails'. Each is drawn as the menu of them.
|
|
15
|
+
MENU_KINDS = %i[enum time_zone].freeze
|
|
16
|
+
|
|
17
|
+
# A payload, under both names an adapter has for one: SQLite and MySQL report a
|
|
18
|
+
# JSON column as `json`, and PostgreSQL's own type reports `jsonb`. Two names for
|
|
19
|
+
# the thing a page does the same with, so the gem asks for either.
|
|
20
|
+
JSON_KINDS = %i[json jsonb].freeze
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Whether a kind is one of those, which is what both pages branch on first.
|
|
25
|
+
def numeric_kind?(kind) = NUMERIC_KINDS.include?(kind)
|
|
26
|
+
|
|
27
|
+
def menu_kind?(kind) = MENU_KINDS.include?(kind)
|
|
28
|
+
|
|
29
|
+
# A month read as the word for one, and a year as the digits it is. Neither counts
|
|
30
|
+
# anything, so neither wears the delimiter a quantity does: 2,025 is a number of
|
|
31
|
+
# things, and 2025 is when they happened.
|
|
32
|
+
def uncounted(kind, value)
|
|
33
|
+
return unless value
|
|
34
|
+
|
|
35
|
+
kind == :month ? Date::MONTHNAMES[value] : value.to_s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Asked in the order that settles it. A counter cache is a counter whatever its
|
|
39
|
+
# column says, since no page may show one and no form may set one; an enum is
|
|
40
|
+
# one however it is stored, and is asked before a list because Rails wraps a
|
|
41
|
+
# column's type to map an enum's words onto it, which reads as a list otherwise;
|
|
42
|
+
# a phone is a phone by its name, the convention the placeholders and the pattern
|
|
43
|
+
# already follow; and everything else is the type the attribute itself reports —
|
|
44
|
+
# `:monetary` included, where a host has registered a type that says so.
|
|
45
|
+
def attribute_kind(column)
|
|
46
|
+
return :counter if resource_model.recourse_counters.key? column
|
|
47
|
+
return :enum if resource_model.defined_enums.key? column
|
|
48
|
+
return :list if Recourse.list_column? resource_model, column
|
|
49
|
+
return :phone if column == 'phone'
|
|
50
|
+
|
|
51
|
+
attribute_type column
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The model's own attribute type, so an `attribute` override still counts and
|
|
55
|
+
# `columns_hash` is never asked.
|
|
56
|
+
def attribute_type(column)
|
|
57
|
+
resource_model.type_for_attribute(column).type
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Columns holding JSON, under whichever name this adapter reports. A payload is a
|
|
61
|
+
# service's answer kept whole — machinery rather than anything a row is about —
|
|
62
|
+
# and one of them is as wide as a page, so no table draws one until a model names
|
|
63
|
+
# it back. Asked through `type_for_attribute` like every other kind, so an
|
|
64
|
+
# `attribute` override counts here too.
|
|
65
|
+
def json_columns
|
|
66
|
+
resource_model.column_names.select { |column| JSON_KINDS.include? attribute_type(column) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# How many decimals the attribute keeps, and how many digits in all. Read from
|
|
70
|
+
# the type rather than the column, and nil for anything that never said.
|
|
71
|
+
def attribute_scale(column)
|
|
72
|
+
resource_model.type_for_attribute(column).scale
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Total digits the attribute holds, the scale included.
|
|
76
|
+
def attribute_precision(column)
|
|
77
|
+
resource_model.type_for_attribute(column).precision
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# What to round a number to, where the attribute says how much it keeps.
|
|
81
|
+
def precision_option(column)
|
|
82
|
+
scale = attribute_scale column
|
|
83
|
+
scale ? { precision: scale } : {}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# The switch under a table saying how much of it one page shows.
|
|
4
|
+
module Limits
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
# It names the size it is not showing, since the sentence beside it already says
|
|
8
|
+
# how much of the table is on the page: what is left to say is where a click
|
|
9
|
+
# goes. A button rather than a link — it performs something rather than leading
|
|
10
|
+
# anywhere, and there is no address for a size the query string is not asked for.
|
|
11
|
+
def limit_link(pagy)
|
|
12
|
+
tag.button limit_label(other_limit(pagy)),
|
|
13
|
+
type: :button, data: { action: 'limit#toggle' },
|
|
14
|
+
class: 'btn btn-link btn-sm p-0 align-baseline recourse-limit'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Two sizes, so the other one is the whole of the choice.
|
|
18
|
+
def other_limit(pagy)
|
|
19
|
+
(Recourse::LIMITS - [pagy.limit]).first
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def limit_label(limit)
|
|
23
|
+
t 'recourse.per_page', limit: limit
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# What the switch behind the button needs: where to keep the choice, and the one
|
|
27
|
+
# size a click writes there. Worked out here rather than in the browser, so the
|
|
28
|
+
# page and the cookie can only ever agree.
|
|
29
|
+
def limit_data(pagy)
|
|
30
|
+
{
|
|
31
|
+
controller: 'limit', limit_storage_value: Recourse::LIMIT_STORAGE,
|
|
32
|
+
limit_to_value: other_limit(pagy),
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# What a foreign key is called, rather than the id that points at it.
|
|
4
|
+
module Names
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
# What one key says: the label of the record it points at, led to that record's
|
|
8
|
+
# page where the routes drew one.
|
|
9
|
+
def named_cell(resource, association)
|
|
10
|
+
record = resource.association(association.name).reader
|
|
11
|
+
|
|
12
|
+
record && led(reference_cell(resource, association), association.klass.name, record.id)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The words of a cell, led to the record's own page where the routes drew one that
|
|
16
|
+
# can be linked to. Here rather than inside `reference_cell`, which also fills a
|
|
17
|
+
# form field's value -- an anchor inside an input is markup a reader would see
|
|
18
|
+
# spelled out. A cell with nothing in it leads nowhere: there would be no words
|
|
19
|
+
# to click.
|
|
20
|
+
def led(said, kind, id)
|
|
21
|
+
path = shown_resource kind
|
|
22
|
+
return said if path.nil? || said.blank?
|
|
23
|
+
|
|
24
|
+
turbo_link_to said, url_for(controller: "/#{path}", action: :show, id:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The resource a kind is read on, where the routes drew one that can be linked to
|
|
28
|
+
# at all: declared at the top level, so no parent id is wanted that a row like
|
|
29
|
+
# this has no way to supply; listing that model; and answering `show`. Nil where
|
|
30
|
+
# any of the three is missing, which is a cell that reads as words rather than one
|
|
31
|
+
# leading somewhere the router would refuse. Remembered per kind and per render:
|
|
32
|
+
# a table asks this once a row, and no route is drawn while a page is drawing.
|
|
33
|
+
def shown_resource(kind)
|
|
34
|
+
@recourse_shown ||= {}
|
|
35
|
+
return @recourse_shown[kind] if @recourse_shown.key? kind
|
|
36
|
+
|
|
37
|
+
@recourse_shown[kind] = Recourse.declared.find do |path|
|
|
38
|
+
Recourse.parent_of(path).nil? && Recourse.model?(path)&.name == kind &&
|
|
39
|
+
routed?(path, 'show')
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# Helpers for the navbar: the trail across it, and the links and forms it draws.
|
|
4
|
+
module Navigation
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
# Trail to the current page as [resource, title, path] triples, opening with
|
|
8
|
+
# the parent a nested page sits under; a nil path is not a link.
|
|
9
|
+
def resource_breadcrumbs
|
|
10
|
+
crumbs = parent_breadcrumbs
|
|
11
|
+
leaf = breadcrumb_leaf
|
|
12
|
+
here = controller.controller_path
|
|
13
|
+
return crumbs << [here, resources_name, nil] unless leaf
|
|
14
|
+
|
|
15
|
+
crumbs << [here, resources_name, index_url] << [nil, leaf, nil]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Where this resource's index is, or nil where it has none, in which case the crumb
|
|
19
|
+
# naming it is read out rather than linked.
|
|
20
|
+
def index_url
|
|
21
|
+
url_for action: :index if routed_action? 'index'
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A link out of a table. Every cell is inside the results frame, and the page a
|
|
25
|
+
# cell links to has no frame of that name, so Turbo would replace the table with
|
|
26
|
+
# `Content missing` rather than leaving the page. `_top` is what leaves it.
|
|
27
|
+
# Takes everything `link_to` takes, and a `data:` of its own still wins.
|
|
28
|
+
def turbo_link_to(name, path, **options)
|
|
29
|
+
data = { turbo_frame: '_top' }.merge options.fetch(:data, {})
|
|
30
|
+
|
|
31
|
+
link_to name, path, **options, data: data
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Where a form submits: the action that saves it, in the namespace the resource
|
|
35
|
+
# was drawn in. `form_with model:` would ask polymorphic routing instead, which
|
|
36
|
+
# knows the model and not the namespace, and names a route that does not exist.
|
|
37
|
+
def resource_form_url(record)
|
|
38
|
+
return url_for action: :create if record.new_record?
|
|
39
|
+
|
|
40
|
+
url_for action: :update, id: record
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Path to this resource's new page, or nil when there is not one to link to.
|
|
44
|
+
def new_resource_path
|
|
45
|
+
return unless routed_action? 'new'
|
|
46
|
+
|
|
47
|
+
url_for action: :new
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# True where `create` is routed with no `new` to draw a form: the navbar then
|
|
51
|
+
# offers a Create button in the Add link's place. Routing it that way is the
|
|
52
|
+
# host saying a bare record can stand — the routes are the whole check.
|
|
53
|
+
def bare_create?
|
|
54
|
+
new_resource_path.nil? && routed_action?('create')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Label for a link to a resource: the icon its model picked, then its title,
|
|
58
|
+
# with the letter at `key` marked where the link answers to one.
|
|
59
|
+
def resource_label(resource, title, key = nil)
|
|
60
|
+
name = key ? shortcut_title(title, key) : title
|
|
61
|
+
icon = Recourse.known_icon resource
|
|
62
|
+
|
|
63
|
+
safe_join [icon && tag.i(class: "bi bi-#{icon}"), name].compact, ' '
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Only a page beneath the index names itself, and names what it is showing.
|
|
67
|
+
def breadcrumb_leaf
|
|
68
|
+
case controller.action_name
|
|
69
|
+
when 'new', 'create' then t 'recourse.new', model: resource_name
|
|
70
|
+
when 'show', 'edit', 'update' then resource_record_label
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# What a page knows about the records either side of it: the one a nested route
|
|
4
|
+
# names above it, and the ones a count or a tab reaches below.
|
|
5
|
+
module Parents
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# The record a nested route names above this page, or nil at the top level.
|
|
9
|
+
def resource_parent
|
|
10
|
+
controller_assign 'recourse_parent'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# The belongs_to that record is reached through, which names the foreign key
|
|
14
|
+
# every row on the page shares.
|
|
15
|
+
def resource_parent_association
|
|
16
|
+
controller_assign 'recourse_parent_association'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The page a nested resource keeps under one record: the index of its children at
|
|
20
|
+
# `/counties/1/zips`, or -- where the routes drew a singular resource -- the one
|
|
21
|
+
# record itself at `/places/5/zip`, reached with no id of its own. The key is
|
|
22
|
+
# named after the path the children are nested under rather than after the page
|
|
23
|
+
# being served, since a page nested one level up is served by another controller
|
|
24
|
+
# entirely.
|
|
25
|
+
def nested_url(record, path, nested)
|
|
26
|
+
url_for controller: "/#{nested}", action: :index,
|
|
27
|
+
"#{path.split('/').last.singularize}_id": record.id
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The crumbs a nested page sits under: the parent's own index, then the record
|
|
31
|
+
# the path names — `Counties`, then `Alameda County`, before `ZIPs`. The
|
|
32
|
+
# record's crumb links to its show page, where one is routed to link to.
|
|
33
|
+
def parent_breadcrumbs
|
|
34
|
+
parent = resource_parent
|
|
35
|
+
return [] unless parent
|
|
36
|
+
|
|
37
|
+
path = Recourse.parent_of controller.controller_path
|
|
38
|
+
[
|
|
39
|
+
[path, Recourse.title(path), parent_url(path, :index)],
|
|
40
|
+
[nil, parent_title(parent), parent_url(path, :show, id: parent)],
|
|
41
|
+
]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Where this resource's own routes are drawn. A nested route answers the
|
|
45
|
+
# collection actions and no more, so a member page — and a count reaching one
|
|
46
|
+
# of the resource's own nested indexes — is looked up above the nesting: this
|
|
47
|
+
# resource's name, drawn where its parent was drawn, so `admin/counties/zips`
|
|
48
|
+
# leaves `admin/zips` however many segments the nesting took. Unless the nesting
|
|
49
|
+
# drew a member page of its own: a county a provider serves is read under that
|
|
50
|
+
# provider and nowhere else, and its pages are where the host put them.
|
|
51
|
+
def resource_controller_path
|
|
52
|
+
path = controller.controller_path
|
|
53
|
+
return path if !resource_parent || routed?(path, 'show') || routed?(path, 'edit')
|
|
54
|
+
|
|
55
|
+
module_of = Recourse.parent_of(path).rpartition('/').first
|
|
56
|
+
|
|
57
|
+
[module_of.presence, path.split('/').last].compact.join '/'
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parent_title(parent)
|
|
61
|
+
# A label is not always words: a numeric one is spoken as a string —
|
|
62
|
+
# `truncate` has no patience for an Integer — and where a record says
|
|
63
|
+
# nothing at all, the model's own name stands in, as the delete warning's
|
|
64
|
+
# title already does.
|
|
65
|
+
label = parent.attributes[parent.class.recourse_label.to_s]
|
|
66
|
+
|
|
67
|
+
# Forty characters of the label, no more: a record named by an address or a
|
|
68
|
+
# sentence would otherwise walk the crumb into the navbar's search form.
|
|
69
|
+
truncate label.presence&.to_s || parent.class.model_name.human, length: 40
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# One of the parent's own pages, or nil where the host drew no route to it —
|
|
73
|
+
# a crumb without a path is read out rather than linked. Both crumbs ask,
|
|
74
|
+
# since a parent reached through a nesting need not be listed or shown at all.
|
|
75
|
+
def parent_url(path, action, **)
|
|
76
|
+
return unless routed? path, action
|
|
77
|
+
|
|
78
|
+
url_for controller: "/#{path}", action:, **
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# The pictures a page draws: an icon on its own, and an icon standing in for a
|
|
4
|
+
# heading where a column is too narrow to carry the word.
|
|
5
|
+
module Pictures
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# One icon, named by the concept it means rather than by what this set calls it.
|
|
9
|
+
# Whatever else the caller hands over — a role, a tooltip's data, a class to hide
|
|
10
|
+
# it by — rides along, a class of the caller's joining the two named here rather
|
|
11
|
+
# than replacing them.
|
|
12
|
+
def icon_tag(concept, label: nil, **options)
|
|
13
|
+
classes = ['bi', "bi-#{Unicon[concept][:bootstrap]}", options.delete(:class)]
|
|
14
|
+
|
|
15
|
+
tag.i class: classes.compact, aria: { label: }, **options
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A heading that is a picture: an action column's, as narrow as the icon in it,
|
|
19
|
+
# and a counter's, headed with what it counts. Named to a screen reader, since
|
|
20
|
+
# an icon alone says nothing to one, and given the tooltip that says the same
|
|
21
|
+
# word to everyone else.
|
|
22
|
+
def icon_heading(concept, title, **)
|
|
23
|
+
icon_tag concept, label: title, role: :img, data: tooltip_on_top(title), **
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def tooltip_on_top(title)
|
|
27
|
+
# `bs_title` is what Bootstrap's tooltip reads, and the controller is what
|
|
28
|
+
# makes one: Bootstrap never wires a tooltip on its own.
|
|
29
|
+
{ controller: 'tooltip', bs_placement: 'top', bs_title: title }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# Fields for a foreign key: a value to type, or a list to pick from.
|
|
4
|
+
module References
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
# The belongs_to a column is the foreign key of, or nil when it is not one.
|
|
8
|
+
# Every key at once and once per render, rather than a scan of the model's
|
|
9
|
+
# associations for each cell of each row that holds one.
|
|
10
|
+
def belongs_to_association(column)
|
|
11
|
+
@recourse_belongs_to ||= resource_model.recourse_references
|
|
12
|
+
.index_by { |one| one.foreign_key.to_s }
|
|
13
|
+
|
|
14
|
+
@recourse_belongs_to[column]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A field for a foreign key. Where the label has a length it is short enough
|
|
18
|
+
# to type, and typing it beats listing every record there is to pick from.
|
|
19
|
+
def reference_field(form, column, association)
|
|
20
|
+
return typed_reference form, column, association if typed_reference? association
|
|
21
|
+
|
|
22
|
+
combobox form, column, association
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# What the record a foreign key points at is called, rather than the id that
|
|
26
|
+
# points at it. `association` reads the target without naming a method for it.
|
|
27
|
+
def reference_cell(resource, association)
|
|
28
|
+
record = resource.association(association.name).reader
|
|
29
|
+
return unless record
|
|
30
|
+
|
|
31
|
+
record.attributes[association.klass.recourse_label.to_s]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Heading for a column, naming the attribute too where one has to be typed:
|
|
35
|
+
# `ZIP code` rather than `ZIP`, since a code is what the field asks for.
|
|
36
|
+
def reference_title(column, association)
|
|
37
|
+
return resource_column_title column unless typed_reference? association
|
|
38
|
+
|
|
39
|
+
association.klass.recourse_reference_name
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def typed_reference?(association)
|
|
43
|
+
association&.klass&.recourse_typed_reference?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def typed_reference(form, column, association)
|
|
47
|
+
messages = errors_on column
|
|
48
|
+
id = form.field_id column
|
|
49
|
+
html = typed_html(column, association).merge(
|
|
50
|
+
class: messages.any? ? 'form-control is-invalid' : 'form-control',
|
|
51
|
+
value: typed_reference_value(form, column, association),
|
|
52
|
+
'aria-describedby': (messages.any? ? "#{id}_error" : field_described(column))
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
safe_join [form.text_field(column, **html), invalid_feedback(messages, id)]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# What the field opens on: the record's own label, so an edit that changes
|
|
59
|
+
# something else does not have to retype this. The request wins where it has
|
|
60
|
+
# anything to say — a code matching nothing was never assigned, so only what
|
|
61
|
+
# was typed is left to show, and a code cleared on purpose stays cleared.
|
|
62
|
+
def typed_reference_value(form, column, association)
|
|
63
|
+
typed = params.dig resource_key, column
|
|
64
|
+
return typed if typed
|
|
65
|
+
|
|
66
|
+
reference_cell form.object, association
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The shape belongs to the attribute typed; whether it is required does not,
|
|
70
|
+
# since that is the association's rule and not the other model's.
|
|
71
|
+
def typed_html(column, association)
|
|
72
|
+
klass = association.klass
|
|
73
|
+
shape = field_html klass.recourse_label.to_s, nil, klass
|
|
74
|
+
own = {
|
|
75
|
+
required: (true if required? resource_model, column), size: nil,
|
|
76
|
+
placeholder: placeholder(resource_model, column, nil),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
shape.except(:required, :placeholder).merge own
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Bootstrap only reveals this next to an `.is-invalid` sibling of its own.
|
|
83
|
+
def invalid_feedback(messages, id)
|
|
84
|
+
return if messages.empty?
|
|
85
|
+
|
|
86
|
+
tag.small messages.to_sentence.upcase_first, class: 'invalid-feedback', id: "#{id}_error"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A belongs_to reports on the association, so `state_id` asks about `state`.
|
|
90
|
+
def errors_on(column)
|
|
91
|
+
attribute = column.delete_suffix '_id'
|
|
92
|
+
messages = resource_record.errors[column]
|
|
93
|
+
return messages if attribute == column
|
|
94
|
+
|
|
95
|
+
messages + resource_record.errors[attribute]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# Subscribes an index page to the refreshes its model broadcasts.
|
|
4
|
+
module Refreshes
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
# The subscription tag, after asking the head for the two metas that make a
|
|
8
|
+
# refresh morph in place and keep the scroll — and nothing at all when the
|
|
9
|
+
# model broadcasts nothing, so a host without turbo-rails is untouched.
|
|
10
|
+
def refresh_subscription
|
|
11
|
+
return unless resource_model.recourse_broadcasting?
|
|
12
|
+
|
|
13
|
+
content_for :head, refresh_metas
|
|
14
|
+
turbo_stream_from resource_model.model_name.plural
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Without these Turbo answers a refresh by replacing the whole body, which
|
|
18
|
+
# drops the caret, the scroll and any menu that was open.
|
|
19
|
+
def refresh_metas
|
|
20
|
+
safe_join [
|
|
21
|
+
tag.meta(name: 'turbo-refresh-method', content: 'morph'),
|
|
22
|
+
tag.meta(name: 'turbo-refresh-scroll', content: 'preserve'),
|
|
23
|
+
]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# What the page is about and what it is called: the model behind it, the record
|
|
4
|
+
# on it, and the words both are read out under.
|
|
5
|
+
module Resources
|
|
6
|
+
# Human, plural name of the resource on the page, e.g. `Contacts`. `known_title`,
|
|
7
|
+
# since a host page wearing this layout may be named after no model at all — a
|
|
8
|
+
# contact's home is a `Location`, and the path is the only word for it.
|
|
9
|
+
def resources_name = Recourse.known_title(controller.controller_name)
|
|
10
|
+
|
|
11
|
+
# Singular, lowercase name of the resource, e.g. 'contact'.
|
|
12
|
+
def resource_name
|
|
13
|
+
Recourse.downcase resource_model.model_name.human
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Local name a row partial receives its record under, e.g. :contact.
|
|
17
|
+
def resource_key
|
|
18
|
+
controller.controller_name.singularize.to_sym
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The record the action built, read from the assigns rather than by ivar name.
|
|
22
|
+
def resource_record
|
|
23
|
+
controller_assign resource_key.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The one door to what the controller assigned. `view_assigns` is public API,
|
|
27
|
+
# and every name the gem reads through it walks this method, so the untyped
|
|
28
|
+
# contract with the controller's ivar names has a single seam.
|
|
29
|
+
def controller_assign(name)
|
|
30
|
+
controller.view_assigns[name]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# What the record on the page is called, by whatever its model labels it with —
|
|
34
|
+
# and nothing where there is no record to name.
|
|
35
|
+
def resource_record_label
|
|
36
|
+
resource_record&.attributes&.dig resource_model.recourse_label.to_s
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Resolved by the controller, where a host may have named a model the route does not.
|
|
42
|
+
def resource_model
|
|
43
|
+
controller_assign('recourse_model') || Recourse.model(controller.controller_name)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# What the router will answer, which is what decides whether a link is drawn.
|
|
4
|
+
module Routing
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
# True where this controller both implements an action and has a route drawn to
|
|
8
|
+
# it. Either alone is a link that 404s or raises. The path is the one being
|
|
9
|
+
# served unless a caller names another: a nested page asks about the resource's
|
|
10
|
+
# own routes, which is where the member actions a nesting leaves out are drawn.
|
|
11
|
+
def routed_action?(action, path = controller.controller_path)
|
|
12
|
+
return false unless controller.class.action_methods.include? action
|
|
13
|
+
|
|
14
|
+
routed? path, action
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def routed?(controller_path, action)
|
|
18
|
+
# The route set as one set of "controller#action" words, built once per
|
|
19
|
+
# render: the table asks four times per row, and routes never change mid-page.
|
|
20
|
+
@recourse_routed ||= Rails.application.routes.routes.to_set do |route|
|
|
21
|
+
"#{route.defaults[:controller]}##{route.defaults[:action]}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@recourse_routed.include? "#{controller_path}##{action}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# The row partial a table renders: the host's for this resource where one is
|
|
4
|
+
# defined — every column its own — and the gem's generic row otherwise.
|
|
5
|
+
module Rows
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
# What the cache key reads so the table notices its row partial. Rails
|
|
9
|
+
# resolves `render 'row'` per request, but the fragment's own digest never
|
|
10
|
+
# follows it there — so a host `_row` added or edited after a fragment was
|
|
11
|
+
# written would keep serving the row it replaced. The digestor walks the
|
|
12
|
+
# resolved template's own dependencies too, so a partial a host's row
|
|
13
|
+
# renders from inside expires the table as well.
|
|
14
|
+
def row_digest
|
|
15
|
+
row = lookup_context.find 'row', lookup_context.prefixes, true
|
|
16
|
+
|
|
17
|
+
ActionView::Digestor.digest name: row.virtual_path, format: :html, finder: lookup_context
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Whether the table may be kept at all. A sorted or filtered one never is: two
|
|
21
|
+
# requests can ask for one relation and want different rows, and only one of them
|
|
22
|
+
# clicked a heading to say so.
|
|
23
|
+
#
|
|
24
|
+
# Nor a table of rows nothing versions. `cache` reads `MAX(updated_at)` off the
|
|
25
|
+
# relation without asking whether there is such a column, and reference data — a
|
|
26
|
+
# table of counties written by a migration and never again — keeps none. The same
|
|
27
|
+
# question `keepable_menu?` asks of a menu, for the same reason.
|
|
28
|
+
def cacheable_table?
|
|
29
|
+
params[:q].blank? && resource_model.column_names.include?('updated_at')
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# The reader's own say in whether a page is light or dark.
|
|
4
|
+
module Schemes
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
# What the sidebar's toggle needs to flip the mode: where to keep what the reader
|
|
8
|
+
# picked, so the layout's script can put it back on the next visit.
|
|
9
|
+
def scheme_data
|
|
10
|
+
{ controller: 'scheme', action: 'scheme#rotate', scheme_storage_value: Recourse::SCHEME_STORAGE }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
module Recourse
|
|
2
|
+
module Helpers
|
|
3
|
+
# The form above a table: the box, its filters, and the marks a match earns.
|
|
4
|
+
module Searches
|
|
5
|
+
# A value with the searched text marked, so a row says why it is in the table.
|
|
6
|
+
# Only what the search looked through is marked: a word marked in a column
|
|
7
|
+
# nobody searched would claim a match that never happened. Public because a
|
|
8
|
+
# row partial of a host's own draws its cells itself and marks them the same.
|
|
9
|
+
def search_highlight(value, column)
|
|
10
|
+
term = query_params[resource_search_field]
|
|
11
|
+
return value if term.blank? || !searched_column?(column)
|
|
12
|
+
|
|
13
|
+
highlight value.to_s, term
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# The form above the table, or nothing at all where the model offers neither
|
|
19
|
+
# of the two things it holds: a box to type in, and menus to narrow by. Either
|
|
20
|
+
# earns it — a table with nothing worth searching may still be worth filtering,
|
|
21
|
+
# and a model whose only columns are an enum and a foreign key is the case.
|
|
22
|
+
def search_form
|
|
23
|
+
field = resource_search_field
|
|
24
|
+
filters = resource_filter_fields
|
|
25
|
+
return if field.blank? && filters.empty?
|
|
26
|
+
|
|
27
|
+
render 'recourses/search', query: resource_search, url: url_for(action: :index),
|
|
28
|
+
field: field, prompt: resource_search_prompt,
|
|
29
|
+
filters: filters, sort: sort_param
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The model's search field, less the reach-through a nested route already
|
|
33
|
+
# answered: a page pinned to one provider offers no box to search them all.
|
|
34
|
+
def resource_search_field
|
|
35
|
+
resource_model.search_field except: resource_parent_association
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def resource_search_prompt
|
|
39
|
+
resource_model.search_prompt except: resource_parent_association
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A foreign key's cell shows a label from the other table, so what decides is
|
|
43
|
+
# whether the search reaches through that association rather than reads a column.
|
|
44
|
+
def searched_column?(column)
|
|
45
|
+
association = belongs_to_association column.to_s
|
|
46
|
+
return resource_model.recourse_searchable_associations.include? association if association
|
|
47
|
+
|
|
48
|
+
resource_model.recourse_searchable_columns.include? column.to_s
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The search the action built, under Ransack's own name for one.
|
|
52
|
+
def resource_search
|
|
53
|
+
controller_assign 'q'
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|