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.
Files changed (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +234 -0
  3. data/README.md +150 -20
  4. data/app/controllers/bookmarks_controller.rb +7 -0
  5. data/app/controllers/concerns/recourse/landing.rb +49 -0
  6. data/app/controllers/concerns/recourse/list_resolution.rb +28 -0
  7. data/app/controllers/concerns/recourse/paging.rb +17 -0
  8. data/app/controllers/concerns/recourse/parent_naming.rb +24 -0
  9. data/app/controllers/concerns/recourse/parent_resolution.rb +63 -0
  10. data/app/controllers/concerns/recourse/reference_resolution.rb +70 -0
  11. data/app/controllers/concerns/recourse/resource_resolution.rb +68 -0
  12. data/app/controllers/concerns/recourse/zoning.rb +29 -0
  13. data/app/controllers/recourse/base_controller.rb +85 -0
  14. data/app/controllers/recourse/bookmarks_controller.rb +76 -0
  15. data/app/controllers/recourses_controller.rb +6 -25
  16. data/app/javascript/recourse/bookmark_controller.js +100 -0
  17. data/app/javascript/recourse/clear_controller.js +22 -0
  18. data/app/javascript/recourse/combobox_controller.js +68 -0
  19. data/app/javascript/recourse/confirm.js +46 -0
  20. data/app/javascript/recourse/deselect_controller.js +37 -0
  21. data/app/javascript/recourse/flash.js +35 -0
  22. data/app/javascript/recourse/limit_controller.js +23 -0
  23. data/app/javascript/recourse/phone_controller.js +33 -0
  24. data/app/javascript/recourse/relative_time_controller.js +45 -0
  25. data/app/javascript/recourse/reveal_controller.js +16 -0
  26. data/app/javascript/recourse/scheme_controller.js +51 -0
  27. data/app/javascript/recourse/search_controller.js +78 -0
  28. data/app/javascript/recourse/shortcuts_controller.js +42 -0
  29. data/app/javascript/recourse/timezone_controller.js +27 -0
  30. data/app/javascript/recourse/toast_controller.js +35 -0
  31. data/app/javascript/recourse/tooltip_controller.js +16 -0
  32. data/app/javascript/recourse/written.js +29 -0
  33. data/app/javascript/recourse/written_controller.js +28 -0
  34. data/app/views/layouts/recourses.html.erb +442 -0
  35. data/app/views/recourses/_breadcrumb.html.erb +35 -0
  36. data/app/views/recourses/_card.html.erb +33 -0
  37. data/app/views/recourses/_color.html.erb +20 -0
  38. data/app/views/recourses/_combobox.html.erb +68 -0
  39. data/app/views/recourses/_confirm.html.erb +14 -0
  40. data/app/views/recourses/_fields.html.erb +3 -0
  41. data/app/views/recourses/_flash.html.erb +26 -0
  42. data/app/views/recourses/_footer.html.erb +22 -0
  43. data/app/views/recourses/_form.html.erb +11 -0
  44. data/app/views/recourses/_none.html.erb +1 -0
  45. data/app/views/recourses/_results.html.erb +10 -0
  46. data/app/views/recourses/_row.html.erb +4 -10
  47. data/app/views/recourses/_scheme.html.erb +14 -0
  48. data/app/views/recourses/_search.html.erb +24 -0
  49. data/app/views/recourses/_sidebar.html.erb +40 -0
  50. data/app/views/recourses/_table.html.erb +47 -42
  51. data/app/views/recourses/_values.html.erb +3 -0
  52. data/app/views/recourses/edit.html.erb +10 -0
  53. data/app/views/recourses/index.html.erb +27 -24
  54. data/app/views/recourses/new.html.erb +3 -0
  55. data/app/views/recourses/show.html.erb +10 -0
  56. data/config/locales/recourse.en.yml +72 -0
  57. data/lib/recourse/bookmarks.rb +51 -0
  58. data/lib/recourse/broadcasting.rb +33 -0
  59. data/lib/recourse/colors.rb +32 -0
  60. data/lib/recourse/columns.rb +80 -0
  61. data/lib/recourse/controllers.rb +26 -0
  62. data/lib/recourse/engine.rb +33 -13
  63. data/lib/recourse/helpers/actions.rb +56 -0
  64. data/lib/recourse/helpers/bookmarks.rb +88 -0
  65. data/lib/recourse/helpers/buttons.rb +65 -0
  66. data/lib/recourse/helpers/cards.rb +67 -0
  67. data/lib/recourse/helpers/cells.rb +89 -0
  68. data/lib/recourse/helpers/choices.rb +49 -0
  69. data/lib/recourse/helpers/colors.rb +20 -0
  70. data/lib/recourse/helpers/comboboxes.rb +87 -0
  71. data/lib/recourse/helpers/constraints.rb +92 -0
  72. data/lib/recourse/helpers/counters.rb +75 -0
  73. data/lib/recourse/helpers/deletions.rb +87 -0
  74. data/lib/recourse/helpers/details.rb +41 -0
  75. data/lib/recourse/helpers/examples.rb +35 -0
  76. data/lib/recourse/helpers/fields.rb +78 -0
  77. data/lib/recourse/helpers/filters.rb +91 -0
  78. data/lib/recourse/helpers/formats.rb +94 -0
  79. data/lib/recourse/helpers/inputs.rb +92 -0
  80. data/lib/recourse/helpers/kinds.rb +87 -0
  81. data/lib/recourse/helpers/limits.rb +37 -0
  82. data/lib/recourse/helpers/names.rb +44 -0
  83. data/lib/recourse/helpers/navigation.rb +75 -0
  84. data/lib/recourse/helpers/parents.rb +82 -0
  85. data/lib/recourse/helpers/pictures.rb +33 -0
  86. data/lib/recourse/helpers/references.rb +99 -0
  87. data/lib/recourse/helpers/refreshes.rb +27 -0
  88. data/lib/recourse/helpers/resources.rb +47 -0
  89. data/lib/recourse/helpers/routing.rb +28 -0
  90. data/lib/recourse/helpers/rows.rb +33 -0
  91. data/lib/recourse/helpers/schemes.rb +14 -0
  92. data/lib/recourse/helpers/searches.rb +57 -0
  93. data/lib/recourse/helpers/shortcuts.rb +30 -0
  94. data/lib/recourse/helpers/sidebars.rb +39 -0
  95. data/lib/recourse/helpers/sorts.rb +62 -0
  96. data/lib/recourse/helpers/tabs.rb +82 -0
  97. data/lib/recourse/helpers/times.rb +43 -0
  98. data/lib/recourse/helpers/values.rb +64 -0
  99. data/lib/recourse/helpers/zones.rb +38 -0
  100. data/lib/recourse/helpers.rb +81 -0
  101. data/lib/recourse/icons.rb +21 -0
  102. data/lib/recourse/limits.rb +12 -0
  103. data/lib/recourse/recoursive.rb +66 -19
  104. data/lib/recourse/registry.rb +36 -0
  105. data/lib/recourse/routes/nested.rb +36 -0
  106. data/lib/recourse/routes.rb +57 -0
  107. data/lib/recourse/routing.rb +11 -32
  108. data/lib/recourse/schemes.rb +6 -0
  109. data/lib/recourse/scopes.rb +37 -0
  110. data/lib/recourse/search.rb +65 -0
  111. data/lib/recourse/searchable/columns.rb +99 -0
  112. data/lib/recourse/searchable/filters.rb +58 -0
  113. data/lib/recourse/searchable/terms.rb +56 -0
  114. data/lib/recourse/searchable.rb +47 -22
  115. data/lib/recourse/titles.rb +56 -0
  116. data/lib/recourse/version.rb +2 -1
  117. data/lib/recourse/writes.rb +12 -0
  118. data/lib/recourse/zones.rb +6 -0
  119. data/lib/recourse.rb +79 -7
  120. data/vendor/recourse/bootstrap-icons.min.css +5 -0
  121. data/vendor/recourse/bootstrap.bundle.min.js +9 -0
  122. data/vendor/recourse/bootstrap.min.css +2 -0
  123. data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
  124. data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
  125. data/vendor/recourse/stimulus.js +2563 -0
  126. metadata +169 -30
  127. data/Rakefile +0 -6
  128. data/app/helpers/navigable_helper.rb +0 -35
  129. data/app/helpers/recoursive_helper.rb +0 -10
  130. data/app/helpers/searchable_helper.rb +0 -54
  131. 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