recourse 3.0.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +234 -0
- data/README.md +150 -20
- data/app/controllers/bookmarks_controller.rb +7 -0
- data/app/controllers/concerns/recourse/landing.rb +49 -0
- data/app/controllers/concerns/recourse/list_resolution.rb +28 -0
- data/app/controllers/concerns/recourse/paging.rb +17 -0
- data/app/controllers/concerns/recourse/parent_naming.rb +24 -0
- data/app/controllers/concerns/recourse/parent_resolution.rb +63 -0
- data/app/controllers/concerns/recourse/reference_resolution.rb +70 -0
- data/app/controllers/concerns/recourse/resource_resolution.rb +68 -0
- data/app/controllers/concerns/recourse/zoning.rb +29 -0
- data/app/controllers/recourse/base_controller.rb +85 -0
- data/app/controllers/recourse/bookmarks_controller.rb +76 -0
- data/app/controllers/recourses_controller.rb +6 -25
- data/app/javascript/recourse/bookmark_controller.js +100 -0
- data/app/javascript/recourse/clear_controller.js +22 -0
- data/app/javascript/recourse/combobox_controller.js +68 -0
- data/app/javascript/recourse/confirm.js +46 -0
- data/app/javascript/recourse/deselect_controller.js +37 -0
- data/app/javascript/recourse/flash.js +35 -0
- data/app/javascript/recourse/limit_controller.js +23 -0
- data/app/javascript/recourse/phone_controller.js +33 -0
- data/app/javascript/recourse/relative_time_controller.js +45 -0
- data/app/javascript/recourse/reveal_controller.js +16 -0
- data/app/javascript/recourse/scheme_controller.js +51 -0
- data/app/javascript/recourse/search_controller.js +78 -0
- data/app/javascript/recourse/shortcuts_controller.js +42 -0
- data/app/javascript/recourse/timezone_controller.js +27 -0
- data/app/javascript/recourse/toast_controller.js +35 -0
- data/app/javascript/recourse/tooltip_controller.js +16 -0
- data/app/javascript/recourse/written.js +29 -0
- data/app/javascript/recourse/written_controller.js +28 -0
- data/app/views/layouts/recourses.html.erb +442 -0
- data/app/views/recourses/_breadcrumb.html.erb +35 -0
- data/app/views/recourses/_card.html.erb +33 -0
- data/app/views/recourses/_color.html.erb +20 -0
- data/app/views/recourses/_combobox.html.erb +68 -0
- data/app/views/recourses/_confirm.html.erb +14 -0
- data/app/views/recourses/_fields.html.erb +3 -0
- data/app/views/recourses/_flash.html.erb +26 -0
- data/app/views/recourses/_footer.html.erb +22 -0
- data/app/views/recourses/_form.html.erb +11 -0
- data/app/views/recourses/_none.html.erb +1 -0
- data/app/views/recourses/_results.html.erb +10 -0
- data/app/views/recourses/_row.html.erb +4 -10
- data/app/views/recourses/_scheme.html.erb +14 -0
- data/app/views/recourses/_search.html.erb +24 -0
- data/app/views/recourses/_sidebar.html.erb +40 -0
- data/app/views/recourses/_table.html.erb +47 -42
- data/app/views/recourses/_values.html.erb +3 -0
- data/app/views/recourses/edit.html.erb +10 -0
- data/app/views/recourses/index.html.erb +27 -24
- data/app/views/recourses/new.html.erb +3 -0
- data/app/views/recourses/show.html.erb +10 -0
- data/config/locales/recourse.en.yml +72 -0
- data/lib/recourse/bookmarks.rb +51 -0
- data/lib/recourse/broadcasting.rb +33 -0
- data/lib/recourse/colors.rb +32 -0
- data/lib/recourse/columns.rb +80 -0
- data/lib/recourse/controllers.rb +26 -0
- data/lib/recourse/engine.rb +33 -13
- data/lib/recourse/helpers/actions.rb +56 -0
- data/lib/recourse/helpers/bookmarks.rb +88 -0
- data/lib/recourse/helpers/buttons.rb +65 -0
- data/lib/recourse/helpers/cards.rb +67 -0
- data/lib/recourse/helpers/cells.rb +89 -0
- data/lib/recourse/helpers/choices.rb +49 -0
- data/lib/recourse/helpers/colors.rb +20 -0
- data/lib/recourse/helpers/comboboxes.rb +87 -0
- data/lib/recourse/helpers/constraints.rb +92 -0
- data/lib/recourse/helpers/counters.rb +75 -0
- data/lib/recourse/helpers/deletions.rb +87 -0
- data/lib/recourse/helpers/details.rb +41 -0
- data/lib/recourse/helpers/examples.rb +35 -0
- data/lib/recourse/helpers/fields.rb +78 -0
- data/lib/recourse/helpers/filters.rb +91 -0
- data/lib/recourse/helpers/formats.rb +94 -0
- data/lib/recourse/helpers/inputs.rb +92 -0
- data/lib/recourse/helpers/kinds.rb +87 -0
- data/lib/recourse/helpers/limits.rb +37 -0
- data/lib/recourse/helpers/names.rb +44 -0
- data/lib/recourse/helpers/navigation.rb +75 -0
- data/lib/recourse/helpers/parents.rb +82 -0
- data/lib/recourse/helpers/pictures.rb +33 -0
- data/lib/recourse/helpers/references.rb +99 -0
- data/lib/recourse/helpers/refreshes.rb +27 -0
- data/lib/recourse/helpers/resources.rb +47 -0
- data/lib/recourse/helpers/routing.rb +28 -0
- data/lib/recourse/helpers/rows.rb +33 -0
- data/lib/recourse/helpers/schemes.rb +14 -0
- data/lib/recourse/helpers/searches.rb +57 -0
- data/lib/recourse/helpers/shortcuts.rb +30 -0
- data/lib/recourse/helpers/sidebars.rb +39 -0
- data/lib/recourse/helpers/sorts.rb +62 -0
- data/lib/recourse/helpers/tabs.rb +82 -0
- data/lib/recourse/helpers/times.rb +43 -0
- data/lib/recourse/helpers/values.rb +64 -0
- data/lib/recourse/helpers/zones.rb +38 -0
- data/lib/recourse/helpers.rb +81 -0
- data/lib/recourse/icons.rb +21 -0
- data/lib/recourse/limits.rb +12 -0
- data/lib/recourse/recoursive.rb +66 -19
- data/lib/recourse/registry.rb +36 -0
- data/lib/recourse/routes/nested.rb +36 -0
- data/lib/recourse/routes.rb +57 -0
- data/lib/recourse/routing.rb +11 -32
- data/lib/recourse/schemes.rb +6 -0
- data/lib/recourse/scopes.rb +37 -0
- data/lib/recourse/search.rb +65 -0
- data/lib/recourse/searchable/columns.rb +99 -0
- data/lib/recourse/searchable/filters.rb +58 -0
- data/lib/recourse/searchable/terms.rb +56 -0
- data/lib/recourse/searchable.rb +47 -22
- data/lib/recourse/titles.rb +56 -0
- data/lib/recourse/version.rb +2 -1
- data/lib/recourse/writes.rb +12 -0
- data/lib/recourse/zones.rb +6 -0
- data/lib/recourse.rb +79 -7
- data/vendor/recourse/bootstrap-icons.min.css +5 -0
- data/vendor/recourse/bootstrap.bundle.min.js +9 -0
- data/vendor/recourse/bootstrap.min.css +2 -0
- data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
- data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
- data/vendor/recourse/stimulus.js +2563 -0
- metadata +169 -30
- data/Rakefile +0 -6
- data/app/helpers/navigable_helper.rb +0 -35
- data/app/helpers/recoursive_helper.rb +0 -10
- data/app/helpers/searchable_helper.rb +0 -54
- data/db/migrate/20260323234318_add_baby_to_posts.rb +0 -5
|
@@ -0,0 +1,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'>·</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
|
-
|
|
2
|
-
<%
|
|
3
|
-
<%= column header: (
|
|
4
|
-
|
|
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:
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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,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
|
-
<%
|
|
1
|
+
<% content_for :title, resources_name %>
|
|
2
2
|
|
|
3
|
-
<%
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|