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,22 @@
1
+ <%# locals: (pagy:) %>
2
+ <% if pagy %>
3
+ <div class='d-flex align-items-center' <%= tag.attributes data: limit_data(pagy) %>>
4
+ <%# The gem's own sentence from pagy's public readers, every figure delimited —
5
+ rewriting pagy's English with a regex delimited one figure, only in English.
6
+ The switch follows it after a dot: how much of the table is showing, then the
7
+ one other amount it could show. Only while there is a second page — a table
8
+ that fits on one is not being paginated, and there is nothing to switch. -%>
9
+ <div class='ps-2 flex-grow-1'>
10
+ <% if pagy.last > 1 %>
11
+ <%= t 'recourse.displaying_slice', from: number_with_delimiter(pagy.from),
12
+ to: number_with_delimiter(pagy.to),
13
+ total: number_with_delimiter(pagy.count) %>
14
+ <span class='fg-2'>&middot;</span>
15
+ <%= limit_link pagy %>
16
+ <% else %>
17
+ <%= t 'recourse.displaying', count: pagy.count %>
18
+ <% end %>
19
+ </div>
20
+ <div class='pe-2'><%== pagy.series_nav(:bootstrap, classes: 'pagination mb-0') if pagy.last > 1 %></div>
21
+ </div>
22
+ <% end %>
@@ -0,0 +1,11 @@
1
+ <% record = local_assigns[resource_key] -%>
2
+ <%# The url is spelled out rather than left to `model:`, which would name a route
3
+ outside whatever namespace the resource was drawn in. -%>
4
+ <%= form_with model: record, url: resource_form_url(record) do |form| %>
5
+ <% @recourse_form = form -%>
6
+ <div class='row'>
7
+ <%= render 'fields', resource_key => record %>
8
+ </div>
9
+
10
+ <%= form.submit class: 'btn btn-solid theme-primary' %>
11
+ <% end %>
@@ -0,0 +1 @@
1
+ <p class='fg-2'><%= t 'recourse.none', models: Recourse.downcase(resources_name) %></p>
@@ -0,0 +1,10 @@
1
+ <%# locals: (resources:, pagy:) %>
2
+ <%# Both branches, so a search that matches nothing still answers with the frame
3
+ it was asked for. `advance` puts the query in the address bar. -%>
4
+ <turbo-frame id='results' data-turbo-action='advance'>
5
+ <% if resources.blank? %>
6
+ <%= render 'none' %>
7
+ <% else %>
8
+ <%= render 'table', recourses: resources, pagy: pagy %>
9
+ <% end %>
10
+ </turbo-frame>
@@ -1,10 +1,4 @@
1
- <%# locals: (recourse:) -%>
2
- <% recourse.attributes.each do |name, value| %>
3
- <%= column header: (recourse.class.ransortable_attributes.include?(name) ? sort_link(@q, name, name.upcase_first) : name) do %>
4
- <% if recourse.class.ransackable_attributes.include? name %>
5
- <%= search_highlight value, model: recourse.class %>
6
- <% else %>
7
- <%= value %>
8
- <% end %>
9
- <% end unless recourse.encrypted_attribute?(name) %>
10
- <% end %>
1
+ <% record = local_assigns[resource_key] -%>
2
+ <% resource_columns.each do |name| -%>
3
+ <%= column header: sort_header(name), class: counter_class(name) do %><%= resource_cell record, name %><% end %>
4
+ <% end -%>
@@ -0,0 +1,14 @@
1
+ <%# The mode a reader chose last time, back before anything is painted. Classic and
2
+ inline rather than a module or a Stimulus controller: both of those run after the
3
+ first paint, which would show the system's mode for an instant and then swap it. -%>
4
+ <script>
5
+ (function () {
6
+ var stored
7
+ try { stored = JSON.parse(localStorage.getItem('<%= Recourse::SCHEME_STORAGE %>')) }
8
+ catch (error) { return }
9
+
10
+ if (stored && (stored.mode === 'light' || stored.mode === 'dark')) {
11
+ document.documentElement.dataset.bsTheme = stored.mode
12
+ }
13
+ })()
14
+ </script>
@@ -0,0 +1,24 @@
1
+ <%# locals: (query:, url:, field:, prompt:, filters:, sort:) -%>
2
+ <%# Never `turbo-permanent`: that spans page visits too, and every index names
3
+ this form the same, so a sidebar click would keep the last resource's form.
4
+ The search controller skips the form's subtree during a morph instead. -%>
5
+ <%= search_form_for query, url: url, html: { class: 'recourse-search d-flex flex-wrap md:flex-nowrap ' \
6
+ 'align-items-center justify-content-end gap-2 ms-auto',
7
+ role: 'search',
8
+ data: { controller: 'search', turbo_frame: :results } } do |form| %>
9
+ <%# Always drawn, even empty: a heading clicked after this was rendered has only
10
+ this field to write the new sort into. -%>
11
+ <%= hidden_field_tag 'q[s]', sort %>
12
+
13
+ <%= safe_join filters %>
14
+
15
+ <%# Only where there is something to search: a model may have nothing worth
16
+ typing at and still be worth narrowing, and the filters above are the form. -%>
17
+ <% if field.present? -%>
18
+ <div class='recourse-search-box form-control form-control-sm form-adorn d-flex'>
19
+ <span class='form-adorn-icon'><i class='bi bi-search'></i></span>
20
+ <%= form.search_field field, class: 'form-ghost', placeholder: prompt, aria: { label: prompt },
21
+ data: { action: 'input->search#submit', search_target: 'field' } %>
22
+ </div>
23
+ <% end -%>
24
+ <% end %>
@@ -0,0 +1,40 @@
1
+ <ul class='nav md:flex-column' data-controller='shortcuts'>
2
+ <% sidebar_resources.each do |name, title, path, key| %>
3
+ <% current = current_resource? name %>
4
+ <li class='nav-item'>
5
+ <%= link_to resource_label(name, title, key), path,
6
+ class: class_names('nav-link', active: current),
7
+ aria: { current: ('page' if current) },
8
+ data: { key: (title[key].downcase if key) } %>
9
+ </li>
10
+ <% end %>
11
+
12
+ <%# A reader's own say in how the page looks: the last item of the sidebar, and at the
13
+ foot of it wherever it is a column. Which side of the fold that lands on is the
14
+ layout's business rather than this file's, and so is which of the two icons shows —
15
+ the mode may be the system's, which only CSS knows, so both are drawn. -%>
16
+ <li class='nav-item recourse-scheme'>
17
+ <%= tag.button type: 'button', class: 'nav-link border-0 bg-transparent',
18
+ data: scheme_data do %>
19
+ <span class='recourse-scheme-light'>
20
+ <i class='bi bi-moon-fill'></i>
21
+ <span class='visually-hidden'><%= t 'recourse.darken' %></span>
22
+ </span>
23
+ <span class='recourse-scheme-dark'>
24
+ <i class='bi bi-sun-fill'></i>
25
+ <span class='visually-hidden'><%= t 'recourse.lighten' %></span>
26
+ </span>
27
+ <% end %>
28
+ </li>
29
+
30
+ <%# And the way out, where the host drew a route named `exit`: a button, since ending
31
+ a session is never a GET, and an icon like the toggle beside it. -%>
32
+ <% if exit? %>
33
+ <li class='nav-item recourse-exit'>
34
+ <%= button_to exit_path, method: :delete, class: 'nav-link border-0 bg-transparent' do %>
35
+ <i class='bi bi-box-arrow-right'></i>
36
+ <span class='visually-hidden'><%= t 'recourse.exit' %></span>
37
+ <% end %>
38
+ </li>
39
+ <% end %>
40
+ </ul>
@@ -1,43 +1,48 @@
1
- <%# locals: (recourses:, pagy: nil, cached: true) -%>
2
- <% name = @lookup_context.find('row', @lookup_context.prefixes, true).short_identifier.split('/')[-2] %>
3
- <% key = name.singularize.to_sym %>
4
-
5
- <%= turbo_frame_tag :results, data: { turbo_action: :advance }, class: 'w-100' do %>
6
- <% if recourses.empty? %>
7
- <p>No <%= controller_path.split('/').last %>.</p>
8
- <% else %>
9
- <%# cache_if cached, [recourses, (recourses.unscoped.size if cached), controller_path].compact do %>
10
- <div class='table-responsive'>
11
- <table class='table caption-top table-hover align-middle md:table-stacked'>
12
- <thead>
13
- <tr>
14
- <% @recourse_headers = true %>
15
- <%= render 'row', key => recourses.first %>
16
- <!-- <th scope='col' class='text-end'>Edit</th> -->
17
- </tr>
18
- </thead>
19
- <tbody>
20
- <% @recourse_headers = false %>
21
- <% recourses.each do |recourse| %>
22
- <%# cache_if (cached && !params.key?(:q)), [recourse, controller_path] do %>
23
- <tr>
24
- <%= render 'row', key => recourse %>
25
- <%#= column header: 'Edit', class: 'text-end' do %>
26
- <%#= edit_link_to action: :edit, id: 1 %>
27
- <%# end %>
28
- </tr>
29
- <%# end %>
30
- <% end %>
31
- </tbody>
32
- </table>
33
- </div>
34
-
35
- <% if pagy %>
36
- <div class='d-flex align-items-center'>
37
- <div class='ps-2 flex-grow-1'><%== pagy.info_tag.gsub(/of (\d*?) in total/) { "of #{number_with_delimiter $1} in total" } %></div>
38
- <div class='pe-2'><%== pagy.series_nav(:bootstrap, classes: 'pagination mb-0') if pagy.last > 1 %></div>
39
- </div>
40
- <% end %>
41
- <%# end %>
42
- <% end %>
1
+ <%# locals: (recourses:, pagy:) %>
2
+ <%# A sorted or filtered table is drawn live: two requests can ask for one relation
3
+ and want different headings, since only one of them clicked a heading to get it. -%>
4
+ <%# The key carries three riders. The row partial's digest: a host's `_row` is
5
+ resolved at render, which the fragment's own template digest never follows.
6
+ And the column list: which columns a table shows is decided in Ruby —
7
+ `recourse_hidden`, timestamps, counters — that no digest covers either. And the
8
+ bookmarks, which are the viewer's rather than the relation's — so this is what
9
+ keeps one person's kept rows off another person's page as well as what expires
10
+ the fragment when a square is clicked. And the zone, for the same reason again:
11
+ a timestamp is drawn against the reader's own clock, so without it the first
12
+ person to load a table would settle what hour everybody else read. -%>
13
+ <% key = [recourses, row_digest, resource_columns, bookmark_digest, Time.zone.name] -%>
14
+ <% cache_if cacheable_table?, key do %>
15
+ <% actions = resource_actions %>
16
+ <div class='table-responsive'>
17
+ <table class='table caption-top table-hover align-middle sm:table-stacked'>
18
+ <thead>
19
+ <tr>
20
+ <% @recourse_headers = true %>
21
+ <%# The square a row is kept by opens the table, ahead of the pages it
22
+ leads to: whether this row is one of mine, before what to do with it. -%>
23
+ <%= column header: bookmark_header, class: 'recourse-actions' if resource_bookmarks %>
24
+ <% actions.each do |action| -%>
25
+ <%= column header: action_header(action), class: 'recourse-actions' %>
26
+ <% end -%>
27
+ <%= render 'row', resource_key => nil %>
28
+ </tr>
29
+ </thead>
30
+ <tbody>
31
+ <% @recourse_headers = false %>
32
+ <% recourses.each do |recourse| %>
33
+ <tr <%= tag.attributes id: Recourse.row_id(recourse), class: bookmark_row_class(recourse) %>>
34
+ <% if resource_bookmarks -%>
35
+ <%= column header: bookmark_header, class: 'recourse-actions' do %><%= bookmark_button recourse %><% end %>
36
+ <% end -%>
37
+ <% actions.each do |action| -%>
38
+ <%= column header: action_header(action), class: 'recourse-actions' do %><%= resource_action_link action, recourse %><% end %>
39
+ <% end -%>
40
+ <%= render 'row', resource_key => recourse %>
41
+ </tr>
42
+ <% end %>
43
+ </tbody>
44
+ </table>
45
+ </div>
43
46
  <% end %>
47
+
48
+ <%= render 'footer', pagy: pagy %>
@@ -0,0 +1,3 @@
1
+ <% shown_columns.each do |name| -%>
2
+ <%= value name %>
3
+ <% end -%>
@@ -0,0 +1,10 @@
1
+ <% content_for :title, resource_record_label %>
2
+
3
+ <% content_for :actions do %>
4
+ <%= destroy_resource_button resource_record %>
5
+ <% end %>
6
+
7
+ <%# The card is the parent's on a page nested under one, as it is for a nested index. -%>
8
+ <%= render layout: 'card', locals: { record: resource_parent || resource_record } do %>
9
+ <%= render 'form', resource_key => resource_record %>
10
+ <% end %>
@@ -1,30 +1,33 @@
1
- <% resources = instance_variable_get("@#{controller_path.split('/').last}") || @resources %>
1
+ <% content_for :title, resources_name %>
2
2
 
3
- <% content_for :title, controller_path.split('/').last.singularize.humanize.pluralize if @where&.empty? %>
4
-
5
- <% model = controller_name.classify.constantize %>
3
+ <% if (new_path = new_resource_path) %>
4
+ <% content_for :actions do %>
5
+ <%= link_to t('recourse.add', model: resource_name), new_path,
6
+ class: 'btn theme-primary btn-sm btn-outline ms-3' %>
7
+ <% end %>
8
+ <% elsif bare_create? %>
9
+ <%# `create` routed without `new`: the host has said there is nothing worth
10
+ asking, so a button posts the record whole and the index returns with it. -%>
11
+ <% content_for :actions do %>
12
+ <%= button_to t('recourse.create'), url_for(action: :create),
13
+ class: 'btn theme-primary btn-sm btn-solid', form_class: 'ms-3' %>
14
+ <% end %>
15
+ <% end %>
6
16
 
7
17
  <% content_for :search do %>
8
- <script>
9
- function debounce(method) {
10
- let timer
11
- return (...args) => {
12
- clearTimeout(timer)
13
- timer = setTimeout(() => { method.apply(this, args) }, 300);
14
- }
15
- }
16
- const debouncedSubmit = debounce((form) => form.requestSubmit())
17
- </script>
18
- <%= search_form q: @q, model: model, url: request.path %>
19
- <% end if model.recourse_searchable? %>
20
-
21
- <% content_for :actions do %>
22
- <% begin %>
23
- <%= link_to "Add #{controller_path.split('/').last.singularize}", url_for(action: :new), class: 'btn theme-primary btn-sm btn-outline ms-3'%>
24
- <% rescue ActionController::UrlGenerationError %>
25
- <% end %>
18
+ <%= search_form %>
26
19
  <% end %>
27
20
 
28
- <% locals = { @lookup_context.find('table', @lookup_context.prefixes, true).short_identifier.split('/')[-2].to_sym => resources } %>
21
+ <%# Outside the frame, so a search keystroke's frame navigation never tears the
22
+ cable connection down and reopens it. -%>
23
+ <%= refresh_subscription %>
29
24
 
30
- <%= render 'table', locals.merge(pagy: @pagy, cached: model.recourse_cachable?) %>
25
+ <%# A nested index is a page of the parent record, so it sits in the record's own
26
+ card, beside the record's other pages — its tab, the count, the current one. -%>
27
+ <% if resource_parent %>
28
+ <%= render layout: 'card', locals: { record: resource_parent } do %>
29
+ <%= render 'results', resources: @resources, pagy: @pagy %>
30
+ <% end %>
31
+ <% else %>
32
+ <%= render 'results', resources: @resources, pagy: @pagy %>
33
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <% content_for :title, t('recourse.new', model: resource_name) %>
2
+
3
+ <%= render 'form', resource_key => resource_record %>
@@ -0,0 +1,10 @@
1
+ <% content_for :title, resource_record_label %>
2
+
3
+ <%# A page nested under a record is that record's page, the way a nested index is:
4
+ the card is the parent's, so the tabs on it are about the record the path names
5
+ above this one rather than about this one. -%>
6
+ <%= render layout: 'card', locals: { record: resource_parent || resource_record } do %>
7
+ <div class='row recourse-values'>
8
+ <%= render 'values', resource_key => resource_record %>
9
+ </div>
10
+ <% end %>
@@ -0,0 +1,72 @@
1
+ en:
2
+ # Namespaced rather than `default`, so a host's own date and time formats stand.
3
+ date:
4
+ formats:
5
+ recourse: "%b %-d, %Y"
6
+ time:
7
+ formats:
8
+ recourse: "%b %-d at %I:%M%P %Z"
9
+ recourse:
10
+ add: Add %{model}
11
+ ago: "%{distance} ago"
12
+ all: All %{models}
13
+ all_values: All
14
+ blank: "—"
15
+ bookmark: Bookmark
16
+ bookmark_added: Bookmark added
17
+ bookmark_error: Bookmark could not be saved.
18
+ bookmark_removed: Bookmark removed
19
+ cancel: Cancel
20
+ clear: Clear search
21
+ create: Create
22
+ created: "%{model} was created."
23
+ created_error: "%{model} could not be created."
24
+ darken: Darken the page
25
+ delete: Delete %{model}
26
+ deleted: "%{model} was deleted."
27
+ exit: Log out
28
+ displaying:
29
+ one: Displaying 1 item
30
+ other: Displaying %{count} items
31
+ displaying_slice: Displaying items %{from}-%{to} of %{total} in total
32
+ edit: Edit
33
+ format: Please match the format %{example}
34
+ from_now: "in %{distance}"
35
+ # How many values a list holds, which is what its summary reads before anybody
36
+ # opens it.
37
+ items:
38
+ one: 1 item
39
+ other: "%{count} items"
40
+ lighten: Lighten the page
41
+ missing_model: You declared `recourses :%{name}` in your routes file, but this app
42
+ has no %{model} model.
43
+ nested: You nested `recourses %{names}` inside `resources :%{parent}`, which does
44
+ not namespace what it holds. Nest it under `recourses :%{parent}` instead.
45
+ # What a menu with several picks reads as, the first named and the rest counted.
46
+ more: "%{first} + %{count} more"
47
+ new: New %{model}
48
+ no_results: No results found
49
+ none: "No %{models}."
50
+ optional: Optional
51
+ per_page: "%{limit} per page"
52
+ ambiguous: "matches more than one record, so it does not say which: %{label}"
53
+ reference: "%{model} %{attribute}"
54
+ reveal: Show
55
+ search: Search…
56
+ searched_cont: Filter by %{list}
57
+ searched_eq: Filter by exact %{list}
58
+ select: Select…
59
+ show: Show
60
+ unbookmark: Remove bookmark
61
+ unknown_color: "`Recourse.color` is one of %{colors}, or nil for Bootstrap's own
62
+ blue. It cannot be %{color}."
63
+ updated: "%{model} was updated."
64
+ updated_error: "%{model} could not be updated."
65
+ unset: None
66
+ deletion:
67
+ delete: Delete
68
+ title: Delete %{record}?
69
+ going: "%{list} will be deleted with it."
70
+ staying: "%{list} will be kept, without its %{model}."
71
+ under: Anything under those goes too.
72
+ undone: This cannot be undone.
@@ -0,0 +1,51 @@
1
+ # Reopened for the one thing a host says about how a viewer keeps a row.
2
+ module Recourse
3
+ class << self
4
+ # What a host wrote in the initializer, kept unresolved: a Proc is the only shape
5
+ # that can name the viewer, since a relation built at boot would hold the agent
6
+ # who was signed in then — which is nobody.
7
+ attr_reader :declared_bookmarks
8
+ end
9
+
10
+ # How bookmarks are stored: a Proc answering the viewer's rows, or a relation or a
11
+ # model where nobody in particular is looking. Nil turns the column off everywhere.
12
+ def self.bookmarks=(bookmarks)
13
+ @declared_bookmarks = bookmarks
14
+ end
15
+
16
+ # Whether a host declared any, asked while the routes draw — where resolving one
17
+ # would run a host's Proc long before there is a request for it to read.
18
+ def self.bookmarks? = !@declared_bookmarks.nil?
19
+
20
+ # The viewer's bookmarks, resolved for this request.
21
+ def self.bookmarks
22
+ bookmarks = @declared_bookmarks
23
+ bookmarks = bookmarks.call if bookmarks.respond_to? :call
24
+
25
+ bookmarks&.all
26
+ end
27
+
28
+ # How a model's bookmarks point back at it: its own `has_many` at the bookmark
29
+ # class, which is both the opt-in and everything needed to write one. A model that
30
+ # cannot hold a bookmark has not declared one, so it gets no column.
31
+ def self.bookmarks_for(model)
32
+ return unless bookmarks?
33
+
34
+ klass = bookmarks.klass
35
+ model.reflect_on_all_associations(:has_many).find { |one| one.klass == klass }
36
+ end
37
+
38
+ # The viewer's bookmarks of one model: the type narrows them where the association
39
+ # is polymorphic, and where it is not there is nothing to narrow by — `type` is set
40
+ # from `as:` and nil without it, so a host whose bookmarks belong to one model needs
41
+ # no type column and is asked for none. The ids behind the icons, the row a click
42
+ # writes and the row a click drops all start here.
43
+ def self.bookmarks_of(reflection)
44
+ return bookmarks unless reflection.type
45
+
46
+ # `polymorphic_name` rather than the class's own: it resolves through `base_class`,
47
+ # which is what Rails writes into the column, so a subclass stays filed with the
48
+ # table it shares.
49
+ bookmarks.where reflection.type => reflection.polymorphic_name
50
+ end
51
+ end
@@ -0,0 +1,33 @@
1
+ require 'active_support'
2
+
3
+ module Recourse
4
+ # Extends every Active Record model with the refreshes its index listens for, so
5
+ # a table redraws itself wherever it is open when a row of it changes.
6
+ module Broadcasting
7
+ # Whether saving a record refreshes every open index listing it. On for every
8
+ # recoursed model; a host quiets one with `def recourse_broadcasts? = false`.
9
+ def recourse_broadcasts? = true
10
+
11
+ # True once the refresh broadcasts are attached, which is also what the index
12
+ # checks before subscribing: a stream nobody broadcasts on is not worth a socket.
13
+ def recourse_broadcasting? = @recourse_broadcasting || false
14
+
15
+ # Attaches the broadcasts, once per class: every committed change goes to the
16
+ # model's plural stream, the one its index subscribes to — plain
17
+ # `broadcasts_refreshes` would send updates and destroys to per-record streams
18
+ # the index never hears. The ivar dies with the class on a dev reload, so the
19
+ # next request attaches to the fresh class again. Quiet without turbo-rails and
20
+ # Active Job, which is when the model has no `broadcasts_refreshes_to`.
21
+ def recourse_broadcast
22
+ return if recourse_broadcasting? || !recourse_broadcasts?
23
+ return unless respond_to? :broadcasts_refreshes_to
24
+
25
+ @recourse_broadcasting = true
26
+ broadcasts_refreshes_to ->(record) { record.model_name.plural }
27
+ end
28
+ end
29
+ end
30
+
31
+ ActiveSupport.on_load :active_record do
32
+ extend Recourse::Broadcasting
33
+ end
@@ -0,0 +1,32 @@
1
+ # Reopened for the one thing a host says about how every page looks.
2
+ module Recourse
3
+ # Color families a host may call primary. Six of the sixteen Bootstrap ships; the
4
+ # other ten are declined rather than forgotten.
5
+ COLORS = %i[blue gray orange purple pink brown].freeze
6
+
7
+ # Families whose 500 step carries dark text rather than white, because white on it
8
+ # reads worse. The dark one is `--bs-gray-975`, the darkest neutral a palette has:
9
+ # upstream already gives warning and info a dark label for the same reason, and white
10
+ # on `orange-500` is 2.90:1, under the 3:1 a button's label owes.
11
+ DARK_INKS = %i[amber blue brown cyan gray green lime orange pewter teal yellow].freeze
12
+
13
+ class << self
14
+ # Which family the pages call primary, or nil for Bootstrap's own blue.
15
+ attr_reader :color
16
+ end
17
+
18
+ # Picks the primary color, and says which six there are when handed anything else. A
19
+ # typo would otherwise write `var(--bs-purpel-500)` into every page and go unnoticed
20
+ # until somebody looked at a button.
21
+ def self.color=(color)
22
+ name = color&.to_sym
23
+ unless name.nil? || COLORS.include?(name)
24
+ raise Error, I18n.t('recourse.unknown_color', color:, colors: COLORS.to_sentence)
25
+ end
26
+
27
+ @color = name
28
+ end
29
+
30
+ # Which text a family's 500 step carries, named as the token upstream spells it with.
31
+ def self.ink(family) = DARK_INKS.include?(family) ? 'gray-975' : 'white'
32
+ end
@@ -0,0 +1,80 @@
1
+ # Reopened for the order a row reads in, which a table, a show page and a form all ask
2
+ # for the same way.
3
+ module Recourse
4
+ # Which part of a row a column belongs to. What a column holds is the gem's to know
5
+ # and where the schema put it is the host's, so both have a say: the kind picks the
6
+ # band, and the order inside the band is the one the table already has. Extended onto
7
+ # `Recourse`, so this is `Recourse.ordered` wherever it is called from.
8
+ module Columns
9
+ # The bands, in the order a row reads. What kind of row this is and what state it is
10
+ # in, whose it is, what it says, its flags, the long values a narrow column suits
11
+ # least, when it happened, the two Rails keeps — and last of all the counts, which
12
+ # say nothing about the row itself, only how much hangs off it. So they close the
13
+ # row at the far edge rather than opening it beside the buttons they resemble.
14
+ #
15
+ # A flag follows what the row says rather than leading it. A yes or a no is narrow
16
+ # enough to have led on width alone, but it reads as a note *about* the row, and a
17
+ # reader scanning a table is looking for the name it belongs to first.
18
+ BANDS = %i[state reference scalar boolean long date timestamp counter].freeze
19
+
20
+ # Values that are paragraphs rather than words, under every name an adapter has for
21
+ # them: PostgreSQL reports `jsonb` where SQLite and MySQL report `json`.
22
+ LONG_KINDS = %i[text json jsonb].freeze
23
+
24
+ # And the ones that are a point in time, whichever part of one they keep.
25
+ DATE_KINDS = %i[date datetime time].freeze
26
+
27
+ # Whether a column holds a list of values rather than one. A PostgreSQL array
28
+ # reports a type wrapping a subtype, and so does a `serialize` of an Array; asked
29
+ # that way rather than by an adapter's own class, which only exists where that
30
+ # adapter is loaded. But wrapping a subtype is not enough on its own: an enum and a
31
+ # range always answered the same way, and since Rails 8.2 so does every decorator
32
+ # a column may wear — time zone conversion on a timestamp, `normalizes`, a lock. What
33
+ # sets a list apart is that its value is changed in place, which `Mutable` marks and
34
+ # none of those are.
35
+ def list_column?(model, column)
36
+ type = model.type_for_attribute column
37
+
38
+ type.respond_to?(:subtype) && type.is_a?(ActiveModel::Type::Helpers::Mutable)
39
+ end
40
+
41
+ # The columns of a model in the order a row reads them, given whichever of them the
42
+ # caller is drawing. Grouped rather than sorted: `group_by` keeps the order it was
43
+ # given inside each group, which is what leaves an order the schema already carries
44
+ # standing, and `sort_by` would not — Ruby's sort is not stable.
45
+ def ordered(model, names)
46
+ keys = reference_keys model
47
+
48
+ names.group_by { |name| BANDS.index band(model, name, keys) }.sort.flat_map(&:last)
49
+ end
50
+
51
+ private
52
+
53
+ # Asked in the order that settles it. A counter is one whatever it is stored as; the
54
+ # column Rails keeps a subclass in says what kind of row this is, as an enum says
55
+ # what state it is in; and a key is an integer, so it has to be recognised as a key
56
+ # before its type is asked about at all.
57
+ def band(model, name, keys)
58
+ return :counter if model.recourse_counters.key? name
59
+ return :state if name == model.inheritance_column || model.defined_enums.key?(name)
60
+ return :timestamp if TIMESTAMPS.include? name
61
+ return :reference if keys.include? name
62
+
63
+ band_of model.type_for_attribute(name).type
64
+ end
65
+
66
+ def band_of(kind)
67
+ return :boolean if kind == :boolean
68
+ return :long if LONG_KINDS.include? kind
69
+ return :date if DATE_KINDS.include? kind
70
+
71
+ :scalar
72
+ end
73
+
74
+ def reference_keys(model)
75
+ model.recourse_references.map { |reference| reference.foreign_key.to_s }
76
+ end
77
+ end
78
+
79
+ extend Columns
80
+ end
@@ -0,0 +1,26 @@
1
+ module Recourse
2
+ # Defines the controller classes a host app has not written for itself.
3
+ module Controllers
4
+ # Creates a controller for the named resource unless the host app has one. The
5
+ # name arrives with whatever namespace it was drawn in — `admin/contacts` — so
6
+ # the constant lands where Rails will look for it.
7
+ def self.define_missing(path, base = nil)
8
+ class_name = "#{path.camelize}Controller"
9
+ # const_defined? is true for a Zeitwerk autoload, so files on disk count too.
10
+ return if Object.const_defined? class_name
11
+
12
+ namespace(class_name.deconstantize).const_set class_name.demodulize,
13
+ Class.new(base || RecoursesController)
14
+ end
15
+
16
+ # The module a namespaced controller belongs in, made where the host has none of
17
+ # its own: `namespace :admin` draws routes whether or not an `Admin` exists.
18
+ private_class_method def self.namespace(name)
19
+ name.split('::').reduce Object do |scope, part|
20
+ next scope.const_get part, false if scope.const_defined? part, false
21
+
22
+ scope.const_set part, Module.new
23
+ end
24
+ end
25
+ end
26
+ end