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
@@ -1,20 +1,40 @@
1
- # A module to manage administered resources.
2
- module Recourse
3
- # Common acronyms
4
- ACRONYMS = %w[API CRM OpenAI PIN URL ZIP]
1
+ # rails/engine alone raises NoMethodError: railtie.rb calls delegate_missing_to too early.
2
+ require 'rails'
3
+
4
+ require_relative 'routes'
5
5
 
6
- # Makes Recourse methods like `recourses` available in config/routes.rb
6
+ module Recourse
7
+ # Hooks the gem into a host app's boot so its controllers and views are found.
7
8
  class Engine < ::Rails::Engine
8
- initializer 'recourse.inflections' do
9
- ActiveSupport::Inflector.inflections do |inflect|
10
- ACRONYMS.each { |acronym| inflect.acronym acronym }
11
- end
9
+ # Prefix the gem answers for with a file. `Rack::Static` matches on
10
+ # `start_with?`, so dropping the slash would swallow `/recourses` itself.
11
+ STATIC_URLS = %w[/recourse/].freeze
12
+
13
+ # Initializers all run before routes are drawn, so `recourses` exists in time.
14
+ initializer 'recourse.routes' do
15
+ ActionDispatch::Routing::Mapper.include Scopes, Routes
12
16
  end
13
17
 
14
- config.to_prepare do
15
- require 'recourse/routing'
16
- require 'recourse/recoursive'
17
- require 'recourse/searchable'
18
+ # Serves what a page needs, since a host may run no asset pipeline at all. The
19
+ # prefix keeps its slash: without it `/recourses` would be served as a file.
20
+ # Turbo answers first, by exact path — matched on the prefix alone, the statics
21
+ # below would swallow it with a 404 instead of passing it on. Then vendored
22
+ # files, cascading so our own JavaScript shares the URL. All of it sits before
23
+ # `Rails::Rack::Logger`, so fetching a stylesheet writes no `Started GET` line:
24
+ # a file is not what a log is about, and placement says so without the gem
25
+ # touching a host's logging.
26
+ initializer 'recourse.assets' do |app|
27
+ # turbo-rails' own bundle: the same Turbo plus the cable element, in the
28
+ # version that gem signs its streams for. A dependency rather than something
29
+ # to find, since a page here cannot do without it.
30
+ app.middleware.insert_before Rails::Rack::Logger, Rack::Static,
31
+ urls: { '/recourse/turbo.min.js' => 'turbo.min.js' },
32
+ root: Turbo::Engine.root.join('app/assets/javascripts').to_s
33
+ app.middleware.insert_before Rails::Rack::Logger, Rack::Static,
34
+ urls: STATIC_URLS, root: Engine.root.join('vendor'),
35
+ cascade: true
36
+ app.middleware.insert_before Rails::Rack::Logger, Rack::Static,
37
+ urls: STATIC_URLS, root: Engine.root.join('app/javascript')
18
38
  end
19
39
  end
20
40
  end
@@ -0,0 +1,56 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The action columns a row opens with: a look at a record, then a change to it.
4
+ module Actions
5
+ # The two pages a record has, named as the concepts an icon set knows rather
6
+ # than as one set's own word for them, so what draws them is Unicon's business
7
+ # here as everywhere else. A row's links and a card's tabs read the same map,
8
+ # so the two cannot drift apart — and its order is the order a row opens with.
9
+ ICONS = { show: :view, edit: :edit }.freeze
10
+
11
+ private
12
+
13
+ # Which of those two this table draws a column for. Read into a local by the
14
+ # table, so the routes are asked once per render rather than twice for the
15
+ # heading and twice more for every row.
16
+ def resource_actions
17
+ ICONS.keys.select { |action| resource_action? action }
18
+ end
19
+
20
+ # An action column's heading: the icon on the header row — the column is as
21
+ # narrow as the icon in it, with no room for a word — and the action's own
22
+ # word in every other, which is what each `data-cell` labels itself with.
23
+ def action_header(action)
24
+ label = t "recourse.#{action}"
25
+ return label unless @recourse_headers
26
+
27
+ icon_heading ICONS[action], label
28
+ end
29
+
30
+ # Whether a record's own page is there to be linked to, wherever its routes
31
+ # were drawn: a nested table's rows lead to the resource's own pages, the ones
32
+ # a nested route leaves to it, so the columns are the same either way.
33
+ def resource_action?(action)
34
+ routed_action? action.to_s, resource_controller_path
35
+ end
36
+
37
+ # The icon linking to one of those pages, or nothing where the page is not
38
+ # routed. Which of the two it is, is the whole difference between them.
39
+ def resource_action_link(action, record)
40
+ path = resource_action_path action, record
41
+ return unless path
42
+
43
+ turbo_link_to icon_tag(ICONS[action]), path, aria: { label: t("recourse.#{action}") }
44
+ end
45
+
46
+ # Named by controller rather than by action alone: on a nested page the two
47
+ # differ, and a bare `action:` would look for the member route the nesting
48
+ # does not draw.
49
+ def resource_action_path(action, record)
50
+ return unless resource_action? action
51
+
52
+ url_for controller: "/#{resource_controller_path}", action: action, id: record
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,88 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The bookmark a table's first column carries: one square per row, hollow where
4
+ # the viewer has not kept it and filled where they have.
5
+ module Bookmarks
6
+ private
7
+
8
+ # How this model's bookmarks point back at it, or nil where it keeps none and
9
+ # the column is not drawn at all. Looked up once per render, like the model.
10
+ def resource_bookmarks
11
+ return @resource_bookmarks if defined? @resource_bookmarks
12
+
13
+ @resource_bookmarks = Recourse.bookmarks_for resource_model
14
+ end
15
+
16
+ # The rows this viewer has kept, read once for the page rather than a query a
17
+ # row: a page is twenty rows and the answer is one integer column.
18
+ def bookmarked_ids
19
+ @bookmarked_ids ||= Recourse.bookmarks_of(resource_bookmarks)
20
+ .pluck(resource_bookmarks.foreign_key).to_set
21
+ end
22
+
23
+ # What the table's cache key carries for the bookmarks, and nil where there are
24
+ # none. The rows are the model's own and never change when a bookmark does, so
25
+ # without this a fragment drawn before the last click outlives it — and the
26
+ # ids are the viewer's, so this is also what keeps one agent's icons off
27
+ # another's page. The name leads because an expanded key renders `nil` and `[]`
28
+ # alike.
29
+ def bookmark_digest
30
+ [:bookmarked, *bookmarked_ids.to_a.sort] if resource_bookmarks
31
+ end
32
+
33
+ # The class that tints a kept row, and nothing at all where the viewer has not
34
+ # kept it — or where this model keeps none, since `bookmarked_ids` has no column
35
+ # to read then. The square already says which rows are theirs, and says it to a
36
+ # reader who cannot see a tint; this is what a page of twenty is scanned by.
37
+ def bookmark_row_class(record)
38
+ 'recourse-kept' if resource_bookmarks && bookmarked_ids.include?(record.id)
39
+ end
40
+
41
+ # The column's heading: the hollow square on the header row — the column is as
42
+ # narrow as the icon in it — and the word on every other, which is what each
43
+ # `data-cell` labels itself with, the way an action column's does.
44
+ def bookmark_header
45
+ label = t 'recourse.bookmark'
46
+ return label unless @recourse_headers
47
+
48
+ icon_heading :bookmark, label
49
+ end
50
+
51
+ # Kept or not, one square either way: the same path with the verb reversed, so
52
+ # the button toggles by flipping the method Rails already wrote into the form.
53
+ def bookmark_button(record)
54
+ kept = bookmarked_ids.include? record.id
55
+ label = t "recourse.#{kept ? 'unbookmark' : 'bookmark'}"
56
+
57
+ button_to bookmark_icon(kept, label), bookmark_url(record),
58
+ method: kept ? :delete : :post, form_class: 'd-inline-block',
59
+ class: 'btn btn-sm btn-link p-0 border-0 lh-1',
60
+ **bookmark_data(kept, label)
61
+ end
62
+
63
+ def bookmark_icon(kept, label)
64
+ icon_tag kept ? :bookmarked : :bookmark, label:
65
+ end
66
+
67
+ # What the Stimulus controller flips, and the wording it cannot look up itself:
68
+ # a `.js` file has no `t`, so the message it may have to show travels with it.
69
+ def bookmark_data(kept, label)
70
+ # No tooltip, unlike every other icon here. This one is in every row rather
71
+ # than in a heading, and a label that follows the cursor down a column of
72
+ # squares is noise — the `aria-label` still names it for a reader who cannot
73
+ # see which way the square is filled.
74
+ data = {
75
+ controller: 'bookmark', bookmark_kept_value: kept,
76
+ bookmark_error_value: t('recourse.bookmark_error'),
77
+ }
78
+
79
+ { aria: { label:, pressed: kept }, data: }
80
+ end
81
+
82
+ def bookmark_url(record)
83
+ url_for controller: "/#{resource_controller_path}/bookmarks", action: :create,
84
+ "#{resource_model.model_name.singular}_id": record.id
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,65 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The buttons a record's pages carry: an action the routes drew under it that has
4
+ # no page of its own for a link to sit on.
5
+ module Buttons
6
+ private
7
+
8
+ # A nested resource routed `create` without an `index` is reached from nowhere, so
9
+ # its button lives on the record it hangs off — beside the breadcrumbs. Which of
10
+ # that record's pages is the host's business as much as the button is: the
11
+ # routes say where it goes as well as whether it exists.
12
+ def bare_action_buttons(record)
13
+ Recourse.nested_under(card_path).filter_map do |nested|
14
+ next unless bare_action_page? nested
15
+
16
+ bare_action_button record, nested
17
+ end
18
+ end
19
+
20
+ # Whether this is that page. An index is a page to reach the action from and a
21
+ # `new` is a form to fill in on the way: either one means there is more to this
22
+ # than a button. A nesting with no page anywhere stands on the record's own page,
23
+ # since every other page of the record is about something else — a place's memos
24
+ # are not where a sweep of the place is offered.
25
+ def bare_action_page?(nested)
26
+ return false if routed?(nested, 'index') || routed?(nested, 'new')
27
+
28
+ record_page?
29
+ end
30
+
31
+ # The one page every record has, which is where a pageless action's button goes.
32
+ def record_page?
33
+ !resource_parent && controller.action_name == 'show'
34
+ end
35
+
36
+ # The `create` the routes drew on the collection, which needs no id: a member
37
+ # action wants the row it acts on, and a row is what a table is for.
38
+ def bare_action_button(record, nested)
39
+ return unless routed? nested, 'create'
40
+
41
+ [bare_action_label(nested), bare_action_url(record, nested), :post]
42
+ end
43
+
44
+ # The resource's own word, which a host renames in a locale like any other
45
+ # model: `Add Jobber retrieval` rather than `Add booking exchange`. Led by
46
+ # whatever namespace the routes put between the record and the action, because
47
+ # that is the only thing telling two routes to the same model apart — without it
48
+ # a `quick/memos` action and the `memos` beside it both read `Add memo`. The tab
49
+ # for a nested index is named from the same split, so the two agree.
50
+ def bare_action_label(nested)
51
+ name, namespace = nested_segments nested, card_path
52
+ model = Recourse.downcase Recourse.known_singular(name)
53
+ lead = namespace_words namespace
54
+
55
+ t 'recourse.add', model: [lead.presence, model].compact.join(' ')
56
+ end
57
+
58
+ def bare_action_url(record, nested)
59
+ parent = card_path.split('/').last.singularize
60
+
61
+ url_for controller: "/#{nested}", action: :create, "#{parent}_id": record.id
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,67 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The tabs on the card a record's pages share: a look, a change, and each
4
+ # nested index under the record — counted where the record keeps a count.
5
+ module Cards
6
+ private
7
+
8
+ # The pages of the record the card is about, as `[label, path, current]` — a
9
+ # look first, a change second, then one tab per nested index: `8 ZIPs` where
10
+ # a counter cache answers, the bare `Settings` where none does. On a nested
11
+ # index the card is the parent record's, so the tabs are too, and the nested
12
+ # tab is the current one.
13
+ def resource_tabs(record)
14
+ path = card_path
15
+ actions = %i[show edit].filter_map { |action| action_tab record, path, action }
16
+
17
+ actions + nested_tabs(record, path)
18
+ end
19
+
20
+ # The resource the card belongs to: the parent's, on a page nested under it —
21
+ # read off the routes rather than chopped off this page's own path, which a
22
+ # `namespace` between the two would leave pointing at the namespace.
23
+ def card_path
24
+ return Recourse.parent_of controller.controller_path if resource_parent
25
+
26
+ controller.controller_path
27
+ end
28
+
29
+ def action_tab(record, path, action)
30
+ return unless routed? path, action.to_s
31
+
32
+ [
33
+ tab_label(action), url_for(controller: "/#{path}", action:, id: record),
34
+ current_action_tab?(action),
35
+ ]
36
+ end
37
+
38
+ # `update` redraws the form it rejected, so the edit tab is current there too.
39
+ # On a nested page neither is: the count tab is the page being read.
40
+ def current_action_tab?(action)
41
+ return false if resource_parent
42
+
43
+ action == (controller.action_name == 'show' ? :show : :edit)
44
+ end
45
+
46
+ # One tab per resource nested under the record, in the order routes.rb nested
47
+ # them — the order the sidebar already keeps. The nested index is the whole
48
+ # requirement: a `has_many` of that name decides how the tab reads, and a counter
49
+ # cache whether it carries a number, but neither is what puts it there. A nesting
50
+ # with no index is an action, and its button stands beside the breadcrumbs instead.
51
+ def nested_tabs(record, path)
52
+ Recourse.nested_under(path).filter_map do |nested|
53
+ next unless routed? nested, 'index'
54
+
55
+ name, namespace = nested_segments nested, path
56
+
57
+ [nested_tab_label(record, name, namespace), nested_url(record, path, nested),
58
+ nested == controller.controller_path,]
59
+ end
60
+ end
61
+
62
+ def tab_label(action)
63
+ safe_join [icon_tag(Actions::ICONS[action]), t("recourse.#{action}")], ' '
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,89 @@
1
+ module Recourse
2
+ module Helpers
3
+ # Helpers for the cells of a table and the fields of a form.
4
+ module Cells
5
+ # One cell: a heading in the header row, the block's output in every other.
6
+ # Public because a row partial of a host's own is written out of these, and
7
+ # is rendered once for the header row and once for each row after it.
8
+ def column(header:, **, &)
9
+ return tag.th(header, scope: :col, **) if @recourse_headers
10
+
11
+ tag.td(capture(&), 'data-cell': header, **)
12
+ end
13
+
14
+ private
15
+
16
+ # Columns the table shows: every attribute that is not encrypted and not
17
+ # read-only, less the primary key — an id is how a row is addressed, not
18
+ # something to read about it — in the order `Recourse.ordered` reads a row,
19
+ # which is what leaves the counts last of all, past even the timestamps.
20
+ def resource_columns
21
+ Recourse.ordered resource_model, resource_model.column_names - hidden_columns
22
+ end
23
+
24
+ # What no table shows, less whatever the model asked to draw anyway. Each of
25
+ # the four below is a default the gem picks, and a host is what answers for
26
+ # its own screens — so naming one overrules it.
27
+ def hidden_columns
28
+ columns_hidden_by_default - Array(resource_model.recourse_displayed).map(&:to_s)
29
+ end
30
+
31
+ # Ciphertext, the id that addresses the row, the parent a nested route already
32
+ # names, the timestamps and every JSON payload — what a machine keeps rather than
33
+ # what a row is about — and whatever the model asked to hide, the one of these a
34
+ # host decides without the override above.
35
+ def columns_hidden_by_default
36
+ [
37
+ resource_model.recourse_encrypted_names, resource_model.primary_key, TIMESTAMPS,
38
+ resource_parent_association&.foreign_key, json_columns,
39
+ Recourse.hidden_columns(resource_model),
40
+ ].flatten.compact
41
+ end
42
+
43
+ # Columns a form offers — less the parent a nested route has already
44
+ # answered: a comment under `/posts/2` is for post 2, not for one picked
45
+ # from a menu, so no field asks.
46
+ def editable_columns
47
+ Recourse.editable_columns(resource_model) - Array(resource_parent_association&.foreign_key)
48
+ end
49
+
50
+ # Columns the show page reads out: what the form offers, what the model draws
51
+ # anyway, and the timestamps last — a computed column is read where it is not typed.
52
+ def shown_columns
53
+ drawn = Array(resource_model.recourse_displayed).map(&:to_s) - TIMESTAMPS
54
+ (editable_columns + drawn).uniq + (TIMESTAMPS & resource_model.column_names)
55
+ end
56
+
57
+ # Heading for a column, which a host app can translate like any attribute. A
58
+ # counter is headed with what it counts — `ZIPs`, not `ZIPs count` — since the
59
+ # column holds a number and the heading says what the number is of.
60
+ def resource_column_title(column)
61
+ counted = resource_model.recourse_counters[column]
62
+ return resource_model.human_attribute_name column unless counted
63
+
64
+ Recourse.model_title counted.klass
65
+ end
66
+
67
+ # Value for one cell, formatted according to what the column holds.
68
+ def resource_cell(resource, column)
69
+ association = belongs_to_association column
70
+ return search_highlight named_cell(resource, association), column if association
71
+
72
+ value = resource.attributes[column]
73
+ counted = resource_model.recourse_counters[column]
74
+
75
+ # A count is the bare number — the icon in the heading already says what it
76
+ # counts — linking to the counted rows where a block nested their index here.
77
+ return counter_cell resource, value, counted if counted
78
+
79
+ # A list is counted rather than drawn: the values are the record's own page to
80
+ # read out, and a column of them inside a column of them is not a table.
81
+ return listed_count value if attribute_kind(column) == :list
82
+
83
+ # The same ladder the show page comes down, with the search's own marking
84
+ # handed in: a table is the only page a search ever reached.
85
+ formatted_attribute(column, value) { |text| search_highlight text, column }
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,49 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The filter menus whose options are values rather than records: what a column
4
+ # itself admits, read off the model rather than out of another table.
5
+ module Choices
6
+ private
7
+
8
+ # The values a column admits of itself, where it admits a known set of them:
9
+ # an enum's words, or a boolean's two. Nil for anything else, which is what
10
+ # hands the question on to the key it might be.
11
+ def choice_filter(predicate, column, label)
12
+ return enum_filter predicate, column, label if resource_model.defined_enums.key? column
13
+
14
+ boolean_filter predicate, column, label if boolean_column? column
15
+ end
16
+
17
+ # The two a boolean admits, as the words a table already prints for them. The
18
+ # way back is the bare `All`: a column called `signed` pluralizes to nothing
19
+ # anybody would write, so the line says what it does rather than what it is of.
20
+ def boolean_filter(predicate, column, label)
21
+ title = label || resource_model.human_attribute_name(column)
22
+
23
+ filter_menu predicate, title, %w[true false], t('recourse.all_values')
24
+ end
25
+
26
+ def boolean_column?(column)
27
+ resource_model.column_names.include?(column) &&
28
+ resource_model.type_for_attribute(column).type == :boolean
29
+ end
30
+
31
+ # A menu of the words the column admits, which are the words the form's own menu
32
+ # offers and the badge on a show page reads. Headed by the attribute rather than
33
+ # by a model, since a status is the table's own and not another table's.
34
+ def enum_filter(predicate, column, label)
35
+ title = label || resource_model.human_attribute_name(column)
36
+ values = resource_model.defined_enums[column].keys
37
+
38
+ filter_menu predicate, title, values, choices_all(title)
39
+ end
40
+
41
+ # The way back to no filter at all, named after what the menu is of: `All
42
+ # statuses`, `All CRMs`. An acronym keeps its capitals, the way every title
43
+ # the gem lower-cases does.
44
+ def choices_all(title)
45
+ t 'recourse.all', models: Recourse.downcase(title).pluralize
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,20 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The primary color, where a host has named one.
4
+ module Colors
5
+ private
6
+
7
+ # The `:root` block that makes `Recourse.color` the primary one, or nothing at all
8
+ # where no color is named — Bootstrap's own blue is already there, and a palette
9
+ # names its own lead accent in its own file. It wins on being later rather than on
10
+ # being more specific, every selector being `:root`, so it belongs after both the
11
+ # stylesheet link and the palette's, and never before either.
12
+ def primary_color_style
13
+ color = Recourse.color
14
+ return unless color
15
+
16
+ render 'recourses/color', color: color, ink: Recourse.ink(color)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,87 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The menu a foreign key offers when its label is too long to be typed.
4
+ module Comboboxes
5
+ private
6
+
7
+ # A combobox of labels. The query fetches the two columns the menu shows and
8
+ # nothing else, and the errors are its own work: `field_error_proc` only ever
9
+ # sees the tags a form builder drew, and this is a partial.
10
+ def combobox(form, column, association)
11
+ label = association.klass.recourse_label
12
+
13
+ render 'recourses/combobox', **combobox_locals(form, column),
14
+ label: label.to_s,
15
+ recourses: combobox_options(association.klass, label)
16
+ end
17
+
18
+ # Whichever menu a kind whose values are known in advance is drawn as.
19
+ def menu_field(form, column, kind)
20
+ kind == :enum ? enum_combobox(form, column) : zone_combobox(form, column)
21
+ end
22
+
23
+ # The words an enum admits, as a menu of one. The values are the model's own, so
24
+ # nothing here has to know what any of them mean.
25
+ def enum_combobox(form, column)
26
+ render 'recourses/combobox', **combobox_locals(form, column),
27
+ values: resource_model.defined_enums[column].keys,
28
+ selected: [form.object.attributes[column]].compact
29
+ end
30
+
31
+ # Whether a menu's rows can be kept, which turns on the one thing a relation is
32
+ # versioned by: `cache` reads `MAX(updated_at)` off the table without asking
33
+ # whether there is such a column, and a model Rails keeps no timestamps on has
34
+ # none to read. Reference data is exactly where that happens — a table of states
35
+ # or of postal codes is written by a migration and never again — so those menus
36
+ # are drawn each time rather than kept under a key nothing can version.
37
+ def keepable_menu?(recourses)
38
+ recourses.klass.column_names.include? 'updated_at'
39
+ end
40
+
41
+ # What every combobox needs to know about the column it sets, whatever it offers
42
+ # as choices: the enum one asks for these too, and gives `values:` instead.
43
+ def combobox_locals(form, column)
44
+ messages = errors_on column
45
+ required = required? resource_model, column
46
+
47
+ {
48
+ name: form.field_name(column), id: form.field_id(column), invalid: messages.any?,
49
+ feedback: messages.to_sentence.upcase_first.presence,
50
+ described: combobox_described(form, column, messages),
51
+ placeholder: combobox_placeholder(column), required: required,
52
+ # A menu of records can say which one; only this can say none of them, and
53
+ # a key that may be nothing has to be settable back to it.
54
+ none: (t 'recourse.unset' unless required),
55
+ selected: combobox_selected(form, column),
56
+ }
57
+ end
58
+
59
+ # What the toggle points at: its own error where it has one, and otherwise the
60
+ # note under it. The same order of precedence every other control follows.
61
+ def combobox_described(form, column, messages)
62
+ id = form.field_id column
63
+ return "#{id}_error" if messages.any?
64
+
65
+ field_described column
66
+ end
67
+
68
+ # What the record already holds, as the menu spells its values: an id for a
69
+ # foreign key and the word itself for an enum. Without it a form opens on the
70
+ # placeholder however full the record is — and the plugin's hidden input opens
71
+ # empty with it, so saving would write that emptiness back.
72
+ def combobox_selected(form, column)
73
+ value = form.object&.attributes&.fetch column.to_s, nil
74
+
75
+ Array(value).map(&:to_s)
76
+ end
77
+
78
+ def combobox_options(klass, label)
79
+ klass.select(:id, label).order label
80
+ end
81
+
82
+ def combobox_placeholder(column)
83
+ placeholder(resource_model, column, nil) || t('recourse.select')
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,92 @@
1
+ module Recourse
2
+ module Helpers
3
+ # Turns a model's validators into the HTML that constrains a form field.
4
+ module Constraints
5
+ # Shown for a field whose shape has one canonical example.
6
+ SAMPLE_PLACEHOLDERS = { 'phone' => '555-555-5555', 'email' => 'michael@example.com' }.freeze
7
+
8
+ # What the browser checks where the typed shape differs from the stored one: a
9
+ # phone is ten digits in the database but is typed with its separators.
10
+ DISPLAY_PATTERNS = { 'phone' => '[2-9]\d{2}-[2-9]\d{2}-\d{4}' }.freeze
11
+
12
+ # Hands a phone field to the Stimulus controller that types its separators.
13
+ PHONE_CONTROLLER = {
14
+ controller: 'phone', action: 'keydown->phone#down input->phone#input',
15
+ }.freeze
16
+
17
+ private
18
+
19
+ # Browser-side constraints, every one read from the validators of `model` —
20
+ # which is the page's model unless a field asks about another one's attribute.
21
+ def field_html(column, type = nil, model = resource_model)
22
+ key = (type || column).to_s
23
+ pattern = DISPLAY_PATTERNS[key] || column_pattern(model, column)
24
+
25
+ {
26
+ maxlength: length_option(model, column, :maximum),
27
+ minlength: length_option(model, column, :minimum), pattern:,
28
+ title: title(key, pattern), placeholder: placeholder(model, column, type),
29
+ inputmode: (:numeric if numeric? model, column, pattern),
30
+ required: (true if required? model, column),
31
+ data: (PHONE_CONTROLLER if key == 'phone'),
32
+ }.compact
33
+ end
34
+
35
+ # The canonical sample where the field has one, so the title agrees with the
36
+ # placeholder; otherwise a shape read off the pattern itself.
37
+ def title(key, pattern)
38
+ return unless pattern
39
+
40
+ t 'recourse.format', example: SAMPLE_PLACEHOLDERS[key] || pattern_example(pattern)
41
+ end
42
+
43
+ def length_option(model, column, bound)
44
+ length_options(model, column).values_at(bound, :is).compact.first
45
+ end
46
+
47
+ def length_options(model, column)
48
+ validator(model, column, ActiveModel::Validations::LengthValidator)&.options || {}
49
+ end
50
+
51
+ # An HTML pattern is anchored already, so \A and \z come off the Ruby one.
52
+ def column_pattern(model, column)
53
+ shape = validator model, column, ActiveModel::Validations::FormatValidator
54
+ regexp = shape&.options&.dig :with
55
+ return unless regexp
56
+
57
+ regexp.source.delete_prefix('\A').delete_suffix '\z'
58
+ end
59
+
60
+ # An explicit type states what the field is, so it picks the sample; without
61
+ # one a required field shows the shape it expects and an optional one says so.
62
+ def placeholder(model, column, type)
63
+ sample = SAMPLE_PLACEHOLDERS[(type || column).to_s]
64
+ return sample if sample && (type || required?(model, column))
65
+
66
+ t 'recourse.optional' unless required? model, column
67
+ end
68
+
69
+ def required?(model, column)
70
+ Recourse.validated_names(column).any? { |name| presence_validated? model, name }
71
+ end
72
+
73
+ def presence_validated?(model, attribute)
74
+ validator(model, attribute, ActiveModel::Validations::PresenceValidator).present?
75
+ end
76
+
77
+ def numeric?(model, column, pattern)
78
+ digits_only?(pattern) ||
79
+ validator(model, column, ActiveModel::Validations::NumericalityValidator).present?
80
+ end
81
+
82
+ # `\d` carries a letter, so it has to go before looking for real ones.
83
+ def digits_only?(pattern)
84
+ pattern.present? && !pattern.gsub('\d', '').match?(/[A-Za-z]/)
85
+ end
86
+
87
+ def validator(model, attribute, kind)
88
+ model.validators_on(attribute).find { |one| one.is_a? kind }
89
+ end
90
+ end
91
+ end
92
+ end