recourse 6.0.0 → 7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 635ceebc330e4dabee8a5fc81f7e176039803b5b78373ffe9cb82ded4b4b2730
4
- data.tar.gz: a2a57c8f1a521bd395a40a527b1ed823151df466867c618c58646aaa4de90de3
3
+ metadata.gz: d4c5d237ba9ace6d0bde291127548ea5202ea729de90541d11240a5d2db0dae3
4
+ data.tar.gz: 7f266eaa7ba56f045c692702fa94c4163d09621bedfd3eb963ab025af5efba39
5
5
  SHA512:
6
- metadata.gz: 9ac64fa98077faa73d5a8f0d65bb041ffe447d7843f85724141c33d6d067570e927eeed245a8d95b2eda578327b0fae176fd281941dbb29638a5c4cbe118a8c5
7
- data.tar.gz: ebae99455b03cf6861838da467a79f561ed159eb76be1b7112ab350a47c05d0fa9c3178476021106f3158583d7234ae86465cfc3c069d4caf3da2a096593ed96
6
+ metadata.gz: c03bfff3a9f95fa135fd7aaf1b5b16e8c905dab9431d13bdc139aaf58cf5188d36164e1921260b6ba21fe3829bdea087bcefbf9f2082ddc73e06d6201ccee885
7
+ data.tar.gz: c79417ff860145a2da9a5e987d842f9d7351e1a92c91916a65714ae83b68786f4f6c557d6e60cdd35dbed670d24974831490235a5127cb4f8437962576e7dd7d
data/CHANGELOG.md CHANGED
@@ -7,6 +7,35 @@ For more information about changelogs, check [Keep a Changelog](http://keepachan
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## 7.0.0 - 2026-09-19
11
+
12
+ * [BREAKING] What a page counts is the gem's to work out
13
+
14
+ `recourse_counters` is gone from every model. A column is counted where it holds a
15
+ counter cache, as before, and now also where it is named `<association>_count` for an
16
+ association the model has -- so a figure the app keeps itself, a `has_many through`
17
+ Rails will not cache among them, is drawn as a count without being declared.
18
+
19
+ The rule a host signs up for: a `*_count` column whose prefix names an association must
20
+ hold the count of that association. One naming nothing is an ordinary number, and one
21
+ naming an association it does not count wants another name -- the heading and the link
22
+ a counter draws would both say the wrong thing. `recourse_hidden` takes such a column
23
+ off the screens.
24
+
25
+ * [BREAKING] A record's card is the layout's, not the template's
26
+
27
+ A host writing its own `show`, `edit` or nested `index` for a recoursed model used to
28
+ replace the whole page, card and all, and wrote `render layout: 'recourses/card'` back by
29
+ hand to get the tabs and the buttons beside the trail. The card is drawn around whatever
30
+ template answers now, so a host's page is its body and nothing else.
31
+
32
+ Every host view still wrapping itself draws a card inside a card: drop the wrapper. A page
33
+ that wants the width to itself assigns `@recourse_card = false`.
34
+
35
+ `card_record` is what the card is about -- the parent on a page nested under one, the
36
+ record itself on its look and its change -- which is the rule every hand-written wrapper
37
+ was already passing.
38
+
10
39
  ## 6.0.0 - 2026-09-19
11
40
 
12
41
  * [Change] These pages are drawn from bh
data/README.md CHANGED
@@ -99,6 +99,22 @@ To change the content displayed in the `index` table of a model, override any of
99
99
  | [`recourse_position`](https://rubydoc.info/gems/recourse/Recourse/Recoursive#recourse_position-instance_method) | `'position'` where the model keeps an integer one | the column a reader drags the rows into order by, or `nil` for a table nobody positions |
100
100
  | [`recourse_icon`](https://rubydoc.info/gems/recourse/Recourse/Recoursive#recourse_icon-instance_method) | the model's name | the icon on the sidebar, the crumbs and the tabs |
101
101
 
102
+ A number is drawn as a count, headed with what it counts and linking to the rows behind
103
+ it, when its column holds a counter cache or is named `<association>_count` for an
104
+ association the model has. So a count the app keeps itself — a `has_many through`, which
105
+ Rails will not cache — needs nothing declared:
106
+
107
+ ```ruby
108
+ class Technician < ActiveRecord::Base
109
+ has_many :visits, through: :dispatches # and a visits_count column beside it
110
+ end
111
+ ```
112
+
113
+ Which means a `*_count` column whose prefix names an association must hold the count of
114
+ that association. One naming nothing — `word_count`, `retry_count` — is an ordinary
115
+ number, and one naming an association it does not count wants another name or a place in
116
+ `recourse_hidden`.
117
+
102
118
  For instance, this would yield a more compact `index` than the default configuration:
103
119
 
104
120
  ```ruby
@@ -59,6 +59,20 @@
59
59
  <%= render 'recourses/sidebar' %>
60
60
  </aside>
61
61
 
62
+ <%# A record's pages are drawn in its card whoever wrote the template, so the tabs and
63
+ the buttons beside the trail stay the gem's and a host's own page is its body. Drawn
64
+ here rather than where it is written, since the card is where the bare actions are
65
+ said and the trail below is where they are read. -%>
66
+ <% page = capture do %>
67
+ <% if (record = card_record) %>
68
+ <%= render layout: 'recourses/card', locals: { record: record } do %>
69
+ <%= yield %>
70
+ <% end %>
71
+ <% else %>
72
+ <%= yield %>
73
+ <% end %>
74
+ <% end %>
75
+
62
76
  <nav class='navbar border-bottom'>
63
77
  <%# `row-gap` rather than a margin on the search: a margin is there whether the
64
78
  form wrapped or not, and on the line it shares with the breadcrumb it drops
@@ -74,7 +88,7 @@
74
88
  </nav>
75
89
 
76
90
  <main class='py-4 px-3'>
77
- <%= yield %>
91
+ <%= page %>
78
92
  </main>
79
93
 
80
94
  <%= toasts(data: written_data) { |key, message| written_message key, message } %>
@@ -4,7 +4,4 @@
4
4
  <%= destroy_resource_button resource_record %>
5
5
  <% end %>
6
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 %>
7
+ <%= render 'form', resource_key => resource_record %>
@@ -22,12 +22,4 @@
22
22
  cable connection down and reopens it. -%>
23
23
  <%= refresh_subscription %>
24
24
 
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, week: @recourse_week %>
30
- <% end %>
31
- <% else %>
32
- <%= render 'results', resources: @resources, pagy: @pagy, week: @recourse_week %>
33
- <% end %>
25
+ <%= render 'results', resources: @resources, pagy: @pagy, week: @recourse_week %>
@@ -1,17 +1,12 @@
1
1
  <% content_for :title, resource_record_label %>
2
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
- <%# Nothing to read out where the host found no record: a singular resource is
8
- reached with no id, so the one it stands for is one it may not have yet, and the
9
- page says so rather than reading attributes off nothing. -%>
10
- <% if resource_record %>
11
- <div class='row recourse-values'>
12
- <%= render 'values', resource_key => resource_record %>
13
- </div>
14
- <% else %>
15
- <%= render 'none', models: resource_name %>
16
- <% end %>
3
+ <%# Nothing to read out where the host found no record: a singular resource is reached
4
+ with no id, so the one it stands for is one it may not have yet, and the page says so
5
+ rather than reading attributes off nothing. -%>
6
+ <% if resource_record %>
7
+ <div class='row recourse-values'>
8
+ <%= render 'values', resource_key => resource_record %>
9
+ </div>
10
+ <% else %>
11
+ <%= render 'none', models: resource_name %>
17
12
  <% end %>
@@ -55,7 +55,7 @@ module Recourse
55
55
  # what state it is in; and a key is an integer, so it has to be recognised as a key
56
56
  # before its type is asked about at all.
57
57
  def band(model, name, keys)
58
- return :counter if model.recourse_counters.key? name
58
+ return :counter if Recourse.counters(model).key? name
59
59
  return :state if name == model.inheritance_column || model.defined_enums.key?(name)
60
60
  return :timestamp if TIMESTAMPS.include? name
61
61
  return :reference if keys.include? name
@@ -0,0 +1,40 @@
1
+ # Reopened for what a page counts, which a table, a heading and a tab all read here.
2
+ module Recourse
3
+ # What a page counts, worked out once per class: a reload replaces the classes the
4
+ # reflections belong to, so the engine empties this where it does.
5
+ @counters = {}
6
+
7
+ # The counts a model's pages draw, as `{ column => association }`. A column holding a
8
+ # counter cache answers, read from the `belongs_to` where `counter_cache` is declared;
9
+ # so does any `*_count` column named after an association the model has, which is how a
10
+ # figure the app keeps itself -- a `has_many through`, which Rails will not cache -- is
11
+ # counted like any other. A `*_count` column naming no association is a number and
12
+ # nothing more, and one naming an association it does not count belongs under another
13
+ # name: the heading and the link a counter draws would both say the wrong thing.
14
+ # @param model [Class] the model the page is about.
15
+ # @return [Hash{String => ActiveRecord::Reflection::AbstractReflection}] the counts.
16
+ def self.counters(model)
17
+ @counters[model.name] ||= cached_counters(model).merge counted_columns(model)
18
+ end
19
+
20
+ # Empties what was read off the reflections of classes a reload has replaced.
21
+ # @return [Hash] the empty table.
22
+ def self.forget_counters = @counters.clear
23
+
24
+ private_class_method def self.cached_counters(model)
25
+ model.reflect_on_all_associations(:has_many).filter_map do |association|
26
+ column = association.inverse_of&.counter_cache_column
27
+ [column, association] if column
28
+ end.to_h
29
+ end
30
+
31
+ private_class_method def self.counted_columns(model)
32
+ model.column_names.filter_map do |column|
33
+ name = column.delete_suffix '_count'
34
+ next if name == column
35
+
36
+ association = model.reflect_on_association name
37
+ [column, association] if association
38
+ end.to_h
39
+ end
40
+ end
@@ -15,6 +15,10 @@ module Recourse
15
15
  ActionDispatch::Routing::Mapper.include Scopes, Routes
16
16
  end
17
17
 
18
+ # Reflections belong to the classes a reload replaces, so what was read off them
19
+ # goes with the reload.
20
+ config.to_prepare { Recourse.forget_counters }
21
+
18
22
  # `/counties.map` and `/shifts.cal` are the same page in another shape, so each
19
23
  # format is a name for HTML: without one Rails has no type for the extension and
20
24
  # answers 406.
@@ -5,6 +5,16 @@ module Recourse
5
5
  module Cards
6
6
  private
7
7
 
8
+ # The record whose card this page is drawn in: the parent, on a page nested under
9
+ # one, and the record itself where the page is its look or its change. An index of
10
+ # its own, and a form for a record that is not there yet, stand outside a card. A
11
+ # host drawing a page whole says so by assigning `@recourse_card = false`.
12
+ def card_record
13
+ return if controller_assign('recourse_card') == false
14
+
15
+ resource_parent || (resource_record if controller.action_name.in? %w[show edit])
16
+ end
17
+
8
18
  # The pages of the record the card is about, as `[label, path, current]` — a
9
19
  # look first, a change second, then one tab per nested index: `8 ZIPs` where
10
20
  # a counter cache answers, the bare `Settings` where none does. On a nested
@@ -58,7 +58,7 @@ module Recourse
58
58
  # counter is headed with what it counts — `ZIPs`, not `ZIPs count` — since the
59
59
  # column holds a number and the heading says what the number is of.
60
60
  def resource_column_title(column)
61
- counted = resource_model.recourse_counters[column]
61
+ counted = Recourse.counters(resource_model)[column]
62
62
  return dated_title column unless counted
63
63
 
64
64
  Recourse.model_title counted.klass
@@ -81,7 +81,7 @@ module Recourse
81
81
  value = resource.attributes[column]
82
82
  return blob_link resource, value if blob_filename? column
83
83
 
84
- counted = resource_model.recourse_counters[column]
84
+ counted = Recourse.counters(resource_model)[column]
85
85
 
86
86
  # A count is the bare number — the icon in the heading already says what it
87
87
  # counts — linking to the counted rows where a block nested their index here.
@@ -8,7 +8,7 @@ module Recourse
8
8
  # The class a counter's cells carry, which is what sizes the column like the
9
9
  # action columns beside it rather than like the columns carrying text.
10
10
  def counter_class(column)
11
- 'recourse-counter' if resource_model.recourse_counters.key? column
11
+ 'recourse-counter' if Recourse.counters(resource_model).key? column
12
12
  end
13
13
 
14
14
  # A figure, and the word saying what it counts. The `aria-label` carries both,
@@ -78,7 +78,7 @@ module Recourse
78
78
  def filter_counter(klass)
79
79
  return if resource_parent
80
80
 
81
- klass.recourse_counters.find { |_, one| one.klass == resource_model }&.first
81
+ Recourse.counters(klass).find { |_, one| one.klass == resource_model }&.first
82
82
  end
83
83
 
84
84
  # Never invalid and never required: a filter narrows rather than sets.
@@ -43,7 +43,7 @@ module Recourse
43
43
  # already follow; and everything else is the type the attribute itself reports —
44
44
  # `:monetary` included, where a host has registered a type that says so.
45
45
  def attribute_kind(column)
46
- return :counter if resource_model.recourse_counters.key? column
46
+ return :counter if Recourse.counters(resource_model).key? column
47
47
  return :enum if resource_model.defined_enums.key? column
48
48
  return :list if Recourse.list_column? resource_model, column
49
49
  return :phone if column == 'phone'
@@ -39,7 +39,7 @@ module Recourse
39
39
  # showing the count it was cached with. Read off the rows in memory like the
40
40
  # version is, so this costs no query either.
41
41
  def counters_version(rows)
42
- columns = resource_model.recourse_counters.keys
42
+ columns = Recourse.counters(resource_model).keys
43
43
  return if columns.empty?
44
44
 
45
45
  rows.map { |row| row.attributes.values_at(*columns) }
@@ -44,13 +44,23 @@ module Recourse
44
44
  end
45
45
 
46
46
  # The model's search field, less the reach-through a nested route already
47
- # answered: a page pinned to one provider offers no box to search them all.
47
+ # answered: a page pinned to one provider offers no box to search them all. A
48
+ # host naming a predicate of its own has said what it wants searched, and what
49
+ # it said is read on every page — which is what answering differently from the
50
+ # gem means here.
48
51
  def resource_search_field
49
- resource_model.search_field except: resource_parent_association
52
+ chosen = resource_model.search_field
53
+ return chosen unless chosen == Recourse.search_field(resource_model)
54
+
55
+ Recourse.search_field resource_model, except: resource_parent_association
50
56
  end
51
57
 
58
+ # And the words above it, narrowed the same way and for the same reason.
52
59
  def resource_search_prompt
53
- resource_model.search_prompt except: resource_parent_association
60
+ chosen = resource_model.search_prompt
61
+ return chosen unless chosen == Recourse.search_prompt(resource_model)
62
+
63
+ Recourse.search_prompt resource_model, except: resource_parent_association
54
64
  end
55
65
 
56
66
  # A foreign key's cell shows a label from the other table, so what decides is
@@ -37,7 +37,7 @@ module Recourse
37
37
  # and nobody types anything under it — and `Location address line 1` over a column
38
38
  # of addresses reads as a form's question asked where there is no form.
39
39
  def sort_title(column)
40
- counted = resource_model.recourse_counters[column.to_s]
40
+ counted = Recourse.counters(resource_model)[column.to_s]
41
41
  return counter_title counted if counted && @recourse_headers
42
42
 
43
43
  resource_column_title column.to_s
@@ -100,7 +100,7 @@ module Recourse
100
100
  end
101
101
 
102
102
  def counter_of(model, association)
103
- model.recourse_counters.find { |_, one| one == association }&.first if association
103
+ Recourse.counters(model).find { |_, one| one == association }&.first if association
104
104
  end
105
105
 
106
106
  def join_of(model, association)
@@ -27,18 +27,6 @@ module Recourse
27
27
  # whichever way round it was written.
28
28
  def recourse_displayed = []
29
29
 
30
- # Columns holding a counter cache, each mapped to the association it counts. Read
31
- # from the `belongs_to` on the other side, which is where `counter_cache` is
32
- # declared: a column merely named `quote_count` is not one of these. Worked out
33
- # once per class, the way `recourse_listable?` is: associations are declared at
34
- # load and a reloaded class is a new one, with a memo of its own.
35
- def recourse_counters
36
- @recourse_counters ||= reflect_on_all_associations(:has_many).filter_map do |association|
37
- column = association.inverse_of&.counter_cache_column
38
- [column, association] if column
39
- end.to_h
40
- end
41
-
42
30
  # `ZIP code`: what to call a foreign key pointing here. A form's label, a table's
43
31
  # heading and a search prompt all name the same thing, so they name it once.
44
32
  def recourse_reference_name
@@ -2,28 +2,14 @@ module Recourse
2
2
  module Searchable
3
3
  # What the search box above a table submits, and what it says while it is empty.
4
4
  module Terms
5
- # The predicate a search box submits: everything it looks through at once,
6
- # joined by `or`. Nil where a model has nothing worth looking through, which
7
- # is also what leaves that model's index without the form — filters and all.
8
- # `except:` takes the association a nested route already answered, so a page
9
- # pinned to one provider offers no box to search them all.
10
- def search_field(except: nil)
11
- fields, predicate = recourse_search_terms(except:)
12
- return if fields.empty?
13
-
14
- "#{fields.join '_or_'}_#{predicate}"
15
- end
16
-
17
- # What the search box says while it is empty, naming what it looks through.
18
- def search_prompt(except: nil)
19
- fields, predicate = recourse_search_terms(except:)
20
- return if fields.empty?
5
+ # The predicate a search box submits: everything the model looks through at once,
6
+ # joined by `or`. A host naming its own instead is taken whole, on every page the
7
+ # model is read on.
8
+ def search_field = Recourse.search_field(self)
21
9
 
22
- list = recourse_search_names(except:).join ' or '
23
- I18n.t "recourse.searched_#{predicate}", list: list
24
- end
25
-
26
- private
10
+ # What the search box says while it is empty, naming what it looks through. A
11
+ # host's own words are taken whole here too.
12
+ def search_prompt = Recourse.search_prompt(self)
27
13
 
28
14
  # What a search box looks through, and how it matches: the plaintext columns
29
15
  # and the labels behind foreign keys — less the one `except:` names — on
@@ -43,6 +29,8 @@ module Recourse
43
29
  fields.map { |field| Recourse.downcase recourse_term_name(field) }
44
30
  end
45
31
 
32
+ private
33
+
46
34
  # A term is a column of this model, or a `zip_code` reaching through one of its
47
35
  # foreign keys — which a form and a table already have a name for.
48
36
  def recourse_term_name(field)
@@ -37,7 +37,7 @@ module Recourse
37
37
  # for leaving those out too: hidden from every screen means hidden here.
38
38
  def ransortable_attributes(_auth_object = nil)
39
39
  readable = ransackable_attributes - recourse_encrypted_names
40
- indexed = recourse_indexed_columns + recourse_counters.keys + Recourse::TIMESTAMPS
40
+ indexed = recourse_indexed_columns + Recourse.counters(self).keys + Recourse::TIMESTAMPS
41
41
 
42
42
  keys = recourse_references.map { |one| one.foreign_key.to_s }
43
43
 
@@ -0,0 +1,31 @@
1
+ # Reopened for what a search box submits and what it says, which a page narrows by the
2
+ # association its route has already answered.
3
+ module Recourse
4
+ # The predicate a search box submits: every term the model looks through, joined by
5
+ # `or`. Nil where a model has nothing worth looking through, which is what leaves its
6
+ # index without the form -- filters and all. `except:` drops the association a nested
7
+ # route already answered, so a page pinned to one provider offers no box to search
8
+ # them all.
9
+ # @param model [Class] the model the page is about.
10
+ # @param except [ActiveRecord::Reflection::AbstractReflection, nil] what the route said.
11
+ # @return [String, nil] the predicate, or nothing where there is none.
12
+ def self.search_field(model, except: nil)
13
+ fields, predicate = model.recourse_search_terms(except:)
14
+ return if fields.empty?
15
+
16
+ "#{fields.join '_or_'}_#{predicate}"
17
+ end
18
+
19
+ # What the search box says while it is empty, naming what it looks through.
20
+ # @param model [Class] the model the page is about.
21
+ # @param except [ActiveRecord::Reflection::AbstractReflection, nil] what the route said.
22
+ # @return [String, nil] the words, or nothing where there is nothing to look through.
23
+ def self.search_prompt(model, except: nil)
24
+ fields, predicate = model.recourse_search_terms(except:)
25
+ return if fields.empty?
26
+
27
+ list = model.recourse_search_names(except:).join ' or '
28
+
29
+ I18n.t "recourse.searched_#{predicate}", list: list
30
+ end
31
+ end
@@ -1,4 +1,4 @@
1
1
  module Recourse
2
2
  # Version of the gem, read by the gemspec and by hosts checking compatibility.
3
- VERSION = '6.0.0'
3
+ VERSION = '7.0.0'
4
4
  end
data/lib/recourse.rb CHANGED
@@ -12,6 +12,8 @@ require_relative 'recourse/bookmarks'
12
12
  require_relative 'recourse/calendars'
13
13
  require_relative 'recourse/colors'
14
14
  require_relative 'recourse/columns'
15
+ require_relative 'recourse/counters'
16
+ require_relative 'recourse/searches'
15
17
  require_relative 'recourse/themes'
16
18
  require_relative 'recourse/icons'
17
19
  require_relative 'recourse/controllers'
@@ -59,7 +61,7 @@ module Recourse
59
61
  # `create` permits these. A counter cache is none of a user's business — Rails keeps
60
62
  # it, so a form that offered one would let it be typed over.
61
63
  def self.editable_columns(model)
62
- ordered model, model.column_names - ['id', *TIMESTAMPS] - model.recourse_counters.keys -
64
+ ordered model, model.column_names - ['id', *TIMESTAMPS] - counters(model).keys -
63
65
  hidden_columns(model)
64
66
  end
65
67
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: recourse
3
3
  version: !ruby/object:Gem::Version
4
- version: 6.0.0
4
+ version: 7.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - claudiob
@@ -185,6 +185,7 @@ files:
185
185
  - lib/recourse/colors.rb
186
186
  - lib/recourse/columns.rb
187
187
  - lib/recourse/controllers.rb
188
+ - lib/recourse/counters.rb
188
189
  - lib/recourse/densities.rb
189
190
  - lib/recourse/engine.rb
190
191
  - lib/recourse/helpers.rb
@@ -255,6 +256,7 @@ files:
255
256
  - lib/recourse/searchable/columns.rb
256
257
  - lib/recourse/searchable/filters.rb
257
258
  - lib/recourse/searchable/terms.rb
259
+ - lib/recourse/searches.rb
258
260
  - lib/recourse/themes.rb
259
261
  - lib/recourse/titles.rb
260
262
  - lib/recourse/version.rb