recourse 7.6.1 → 7.7.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: f8e518ee85484be8de5cc4a1533a9b8677d97417706a8c93210a02b2ad067040
4
- data.tar.gz: 98e1b1f4173a12c8935783d5194715affd66ebfd33de377b0010c86352be83c4
3
+ metadata.gz: a39f935e577b873d97305e1b599b01b7c36a7e8f7c07b584df0f2449a2a58042
4
+ data.tar.gz: ccce6838f769dcd9b3bc8bdbb00143b17f1a593e39f58567f3054373335f0a6d
5
5
  SHA512:
6
- metadata.gz: 11c62eca29bcd4fdd171ff3d1fe2f5829b41ff0d4d057cff38a8c24749b5bb8d610962906746ba828e6163d7ad0ac89bb0e7a5a1ef44b84250967b95e4173708
7
- data.tar.gz: f4801d3c52a5228fe075f50940fd01833c81966c232b17e0d093dffda29d0b75f93e73cf91d13fdf05a9a55bd41e7adaad2c84653d8a5f5b79fe7102c8146458
6
+ metadata.gz: 16faf5ac22fbc37539dd84b991b58f7027abbcd26bdedb13ecbcd9d877c966844508d8106eb66e99223bbce9e5998fc81ed9df781e03b6f8864ad036abcc8e62
7
+ data.tar.gz: 3155b9735871a0de16dd9e95ea9375bce9ad062d46827afc4549a30c67cf868bd97d2c252e1062ac798b65612c52d7cf4525d52f654b66b82ee1428641dac134
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ For more information about changelogs, check [Keep a Changelog](http://keepachan
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## 7.7.0 - 2026-09-20
11
+
12
+ * [CHANGE] `filter_fields` is a list of predicates, and nothing else
13
+
14
+ A filter was a predicate to the options that drew it — `label:` for its heading,
15
+ `values:` for its words, `scope:` for the rows it offered. All three are gone: a model
16
+ names the filters it wants and the gem works out the rest. A predicate may now reach
17
+ through an association, so `skilled_specialties_id_in` lists the specialties and
18
+ `integration_type_in` the kinds the integrations table holds, each read as the locale
19
+ calls that model.
20
+
21
+ * [FEATURE] A filter Ransack will not answer is refused
22
+
23
+ A predicate whose attribute is missing from `ransackable_attributes` drew a menu that
24
+ narrowed nothing and handed back every row. Ransack is asked first, since Ransack is
25
+ what would drop it, and a model that names one raises rather than drawing it.
26
+
10
27
  ## 7.6.1 - 2026-09-20
11
28
 
12
29
  * [FIX] The three write seams are marked `@api private`
@@ -74,6 +74,10 @@ en:
74
74
  unindexed: "You asked for `recourses %{names}, positionable: true`, which draws no
75
75
  index to put in order. A row is dragged from the table that lists it, so there is
76
76
  nowhere for a drop to be reported from."
77
+ unanswerable: "%{model} lists `%{predicate}` among its `filter_fields`, and Ransack
78
+ will not answer it: the attribute behind it is missing from
79
+ `ransackable_attributes` or `ransackable_associations`. A menu drawn for it would
80
+ narrow nothing and hand back every row."
77
81
  # What a menu with several picks reads as, the first named and the rest counted.
78
82
  more: "%{first} + %{count} more"
79
83
  new: New %{model}
@@ -9,10 +9,10 @@ module Recourse
9
9
  # The values a column admits of itself, where it admits a known set of them:
10
10
  # an enum's words, or a boolean's two. Nil for anything else, which is what
11
11
  # hands the question on to the key it might be.
12
- def choice_filter(predicate, column, label)
13
- return enum_filter predicate, column, label if resource_model.defined_enums.key? column
12
+ def choice_filter(predicate, column)
13
+ return enum_filter predicate, column if resource_model.defined_enums.key? column
14
14
 
15
- boolean_filter predicate, column, label if boolean_column? column
15
+ boolean_filter predicate, column if boolean_column? column
16
16
  end
17
17
 
18
18
  # The two a boolean admits, read as a reader reads them rather than as the
@@ -21,8 +21,8 @@ module Recourse
21
21
  # `Reachable` over a column called `stopped` -- names them with `values:`. The
22
22
  # way back is the bare `All`: a column called `signed` pluralizes to nothing
23
23
  # anybody would write, so the line says what it does rather than what it is of.
24
- def boolean_filter(predicate, column, label)
25
- title = label || resource_model.human_attribute_name(column)
24
+ def boolean_filter(predicate, column)
25
+ title = resource_model.human_attribute_name column
26
26
  values = [[t('recourse.affirmative'), true], [t('recourse.negative'), false]]
27
27
 
28
28
  filter_menu predicate, title, values, t('recourse.all_values')
@@ -36,8 +36,8 @@ module Recourse
36
36
  # A menu of the words the column admits, which are the words the form's own menu
37
37
  # offers and the badge on a show page reads. Headed by the attribute rather than
38
38
  # by a model, since a status is the table's own and not another table's.
39
- def enum_filter(predicate, column, label)
40
- title = label || resource_model.human_attribute_name(column)
39
+ def enum_filter(predicate, column)
40
+ title = resource_model.human_attribute_name column
41
41
  values = resource_model.defined_enums[column].keys
42
42
 
43
43
  filter_menu predicate, title, values, choices_all(title)
@@ -0,0 +1,54 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The markup a filter menu is: the records it lists, the order they read in, and
4
+ # the count beside each where the model keeps one.
5
+ module FilterMenus
6
+ private
7
+
8
+ def filter_combobox(predicate, title, recourses)
9
+ label = recourses.klass.recourse_label
10
+ counter = filter_counter recourses.klass
11
+ # What it reads as when nothing is ticked, so the way back is a line in the menu.
12
+ models = Recourse.model_title recourses.klass, lower: true
13
+
14
+ filter_menu predicate, title, nil, t('recourse.all', models: models),
15
+ label: label.to_s, counter: counter,
16
+ recourses: filter_options(recourses, label, counter)
17
+ end
18
+
19
+ # The commonest choice first where the model keeps a count, since a menu is read
20
+ # from the top and most requests want the option most rows are behind — and by
21
+ # name where it keeps none. The name breaks ties, or two markets on the same
22
+ # number would swap places between one request and the next.
23
+ def filter_options(recourses, label, counter)
24
+ return recourses.select(:id, label).order label unless counter
25
+
26
+ recourses.select(:id, label, counter).order counter => :desc, label => :asc
27
+ end
28
+
29
+ # The column on the model a filter lists that counts the rows being filtered —
30
+ # `markets.zips_count` on `/zips`. Read from the counter caches that model keeps
31
+ # rather than from a column named after this one, so a `zips_count` nobody
32
+ # maintains is not a count. None on a nested page: the count is of every row in
33
+ # the table, and the page shows the parent's share of them, which nothing counted.
34
+ def filter_counter(klass)
35
+ return if resource_parent
36
+
37
+ Recourse.counters(klass).find { |_, one| one.klass == resource_model }&.first
38
+ end
39
+
40
+ # Never invalid and never required: a filter narrows rather than sets.
41
+ def filter_menu(predicate, title, values, all, **)
42
+ render('recourses/combobox', name: "q[#{predicate}]", id: "q_#{predicate}",
43
+ placeholder: title, multiple: true, aria_label: title,
44
+ selected: filter_values(predicate), small: true,
45
+ all: all, values: Array(values), **)
46
+ end
47
+
48
+ # A multiple select submits one value per pick, and a link carries them the same way.
49
+ def filter_values(predicate)
50
+ Array(query_params[predicate]).map(&:to_s).compact_blank
51
+ end
52
+ end
53
+ end
54
+ end
@@ -10,7 +10,7 @@ module Recourse
10
10
  # rather than picked — so what is left is what a form would hold, which is
11
11
  # what decides whether there is a form at all.
12
12
  def resource_filter_fields
13
- resource_filters.filter_map { |predicate, options| filter_field predicate, **options }
13
+ resource_filters.filter_map { |predicate| filter_field predicate }
14
14
  end
15
15
 
16
16
  # The model's filters, less the one a nested route already answered:
@@ -18,80 +18,48 @@ module Recourse
18
18
  # for it would only offer to re-ask — or to contradict — the address.
19
19
  def resource_filters
20
20
  parent = resource_parent_association
21
- filters = resource_model.filter_fields
21
+ filters = declared_filters
22
22
  return filters unless parent
23
23
 
24
- filters.except "#{parent.foreign_key}_in"
24
+ filters - ["#{parent.foreign_key}_in"]
25
25
  end
26
26
 
27
- # One filter: the words a host named, the values a column of its own admits, or
28
- # a menu of the records a foreign key points at, holding whichever the request
29
- # already asked for. `values:` is the shape for a predicate no column of this
30
- # model describes, and it needs the `label:` there is no column to read one off.
31
- def filter_field(predicate, label: nil, scope: nil, values: nil)
32
- return values_filter predicate, label, values if values
27
+ # The predicates the model named, as words. A filter is named and nothing else:
28
+ # what it reads as and what it offers are the column's to say.
29
+ def declared_filters
30
+ resource_model.filter_fields.map(&:to_s)
31
+ end
32
+
33
+ # One filter: the values a column of its own admits, a menu of the records a
34
+ # foreign key points at, or one reached through an association. A predicate
35
+ # Ransack will not answer is refused here rather than drawn, a menu that
36
+ # narrows nothing being worse than no menu at all.
37
+ def filter_field(predicate)
38
+ refuse_unanswerable predicate
39
+ column = predicate.sub Search::LIST_PREDICATES, ''
33
40
 
34
- column = predicate.to_s.sub Search::LIST_PREDICATES, ''
41
+ choice_filter(predicate, column) || reference_filter(predicate, column) ||
42
+ reached_filter(predicate, column)
43
+ end
35
44
 
36
- choice_filter(predicate, column, label) || reference_filter(predicate, column, label, scope)
45
+ # Ransack decides, since Ransack is what would drop it: a model answers for the
46
+ # attributes it allows and no others, and a filter naming one it does not allow
47
+ # comes back holding every row.
48
+ def refuse_unanswerable(predicate)
49
+ resource_model.ransack({ predicate => ['1'] }, ignore_unknown_conditions: false)
50
+ rescue ::Ransack::InvalidSearchError
51
+ raise Error, I18n.t('recourse.unanswerable', model: resource_model, predicate:)
37
52
  end
38
53
 
39
54
  # A menu of the records a foreign key points at. Nothing where that key is typed
40
55
  # rather than picked, which is the same question the field beside it asks: the
41
56
  # label is bounded, or the table is too long to list. Either way the menu would
42
- # be a table of its own. A `scope:` draws one anyway.
43
- def reference_filter(predicate, column, label, scope)
57
+ # be a table of its own.
58
+ def reference_filter(predicate, column)
44
59
  association = belongs_to_association column
45
- return if association.nil? ||
46
- (scope.nil? && association.klass.recourse_typed_reference?)
47
-
48
- filter_combobox predicate, label || reference_title(column, association),
49
- (scope || association.klass).all
50
- end
51
-
52
- def filter_combobox(predicate, title, recourses)
53
- label = recourses.klass.recourse_label
54
- counter = filter_counter recourses.klass
55
- # What it reads as when nothing is ticked, so the way back is a line in the menu.
56
- models = Recourse.model_title recourses.klass, lower: true
57
-
58
- filter_menu predicate, title, nil, t('recourse.all', models: models),
59
- label: label.to_s, counter: counter,
60
- recourses: filter_options(recourses, label, counter)
61
- end
62
-
63
- # The commonest choice first where the model keeps a count, since a menu is read
64
- # from the top and most requests want the option most rows are behind — and by
65
- # name where it keeps none. The name breaks ties, or two markets on the same
66
- # number would swap places between one request and the next.
67
- def filter_options(recourses, label, counter)
68
- return recourses.select(:id, label).order label unless counter
69
-
70
- recourses.select(:id, label, counter).order counter => :desc, label => :asc
71
- end
72
-
73
- # The column on the model a filter lists that counts the rows being filtered —
74
- # `markets.zips_count` on `/zips`. Read from the counter caches that model keeps
75
- # rather than from a column named after this one, so a `zips_count` nobody
76
- # maintains is not a count. None on a nested page: the count is of every row in
77
- # the table, and the page shows the parent's share of them, which nothing counted.
78
- def filter_counter(klass)
79
- return if resource_parent
80
-
81
- Recourse.counters(klass).find { |_, one| one.klass == resource_model }&.first
82
- end
83
-
84
- # Never invalid and never required: a filter narrows rather than sets.
85
- def filter_menu(predicate, title, values, all, **)
86
- render('recourses/combobox', name: "q[#{predicate}]", id: "q_#{predicate}",
87
- placeholder: title, multiple: true, aria_label: title,
88
- selected: filter_values(predicate), small: true,
89
- all: all, values: Array(values), **)
90
- end
60
+ return if association.nil? || association.klass.recourse_typed_reference?
91
61
 
92
- # A multiple select submits one value per pick, and a link carries them the same way.
93
- def filter_values(predicate)
94
- Array(query_params[predicate]).map(&:to_s).compact_blank
62
+ filter_combobox predicate, reference_title(column, association), association.klass.all
95
63
  end
96
64
  end
97
65
  end
@@ -0,0 +1,66 @@
1
+ module Recourse
2
+ module Helpers
3
+ # A filter that reaches through an association: `skilled_specialties_id_in` on a
4
+ # provider names the specialties it is skilled in, and `integration_type_in` the
5
+ # CRM its integration is. The predicate says which association and which of its
6
+ # columns, and the column says what the menu is of.
7
+ module Reaches
8
+ private
9
+
10
+ # A menu for a column on another model, where the predicate names the way there.
11
+ # The rows a key points at, or the kinds a table holds where the column is the
12
+ # one Rails keeps a subclass in, or whatever else that column holds.
13
+ def reached_filter(predicate, column)
14
+ association, attribute = reached_association column
15
+ return unless association
16
+
17
+ klass = association.klass
18
+ title = klass.model_name.human
19
+ return rows_filter predicate, title, klass if attribute == klass.primary_key
20
+
21
+ held_filter predicate, title, klass, attribute
22
+ end
23
+
24
+ # Every row of the other table, unless it is too long to be a menu — the same
25
+ # question a key pointing at it answers, since a menu of forty thousand buttons
26
+ # is a table wearing the wrong clothes.
27
+ def rows_filter(predicate, title, klass)
28
+ filter_combobox predicate, title, klass.all unless klass.recourse_typed_reference?
29
+ end
30
+
31
+ # The association a column reaches through, and what is left of the column once
32
+ # its name is taken off. The longest name wins, so `listed_contact_id` reaches
33
+ # through `listed_contact` rather than through a `listed` beside it.
34
+ def reached_association(column)
35
+ named = resource_model.reflect_on_all_associations.select do |one|
36
+ column.start_with? "#{one.name}_"
37
+ end
38
+ one = named.max_by { |association| association.name.length }
39
+
40
+ [one, column.delete_prefix("#{one.name}_")] if one
41
+ end
42
+
43
+ # The values a column on the other table holds, read from the rows rather than
44
+ # from anywhere they are declared: a value nobody has written is a filter that
45
+ # would answer nothing, and a column holding more of them than a menu takes is a
46
+ # table wearing the wrong clothes.
47
+ def held_filter(predicate, title, klass, attribute)
48
+ return unless klass.column_names.include? attribute
49
+
50
+ held = klass.distinct.limit(Searchable::MENU_LIMIT + 1).pluck(attribute).compact.sort
51
+ return if held.empty? || held.size > Searchable::MENU_LIMIT
52
+
53
+ values_filter(predicate, title, held.map { |one| [held_title(klass, attribute, one), one] })
54
+ end
55
+
56
+ # What one of them reads as: the word itself, unless the column is the one Rails
57
+ # keeps a subclass in, where it reads as the locale calls that model — the key
58
+ # Rails writes a subclass under, so no class is named to find it.
59
+ def held_title(klass, attribute, value)
60
+ return value unless attribute == klass.inheritance_column
61
+
62
+ t "activerecord.models.#{value.underscore}", default: value.demodulize.titleize
63
+ end
64
+ end
65
+ end
66
+ end
@@ -19,6 +19,7 @@ require_relative 'helpers/details'
19
19
  require_relative 'helpers/events'
20
20
  require_relative 'helpers/examples'
21
21
  require_relative 'helpers/fields'
22
+ require_relative 'helpers/filter_menus'
22
23
  require_relative 'helpers/filters'
23
24
  require_relative 'helpers/flashes'
24
25
  require_relative 'helpers/formats'
@@ -32,6 +33,7 @@ require_relative 'helpers/navigation'
32
33
  require_relative 'helpers/parents'
33
34
  require_relative 'helpers/pictures'
34
35
  require_relative 'helpers/previews'
36
+ require_relative 'helpers/reaches'
35
37
  require_relative 'helpers/references'
36
38
  require_relative 'helpers/resources'
37
39
  require_relative 'helpers/refreshes'
@@ -56,11 +58,11 @@ module Recourse
56
58
  module Helpers
57
59
  include Actions, Positions, Attachments, Blobs, Bookmarks, Breadcrumbs, Buttons,
58
60
  Calendars, Cards, Cells, Choices, Colors, Comboboxes, Constraints, Counters,
59
- Deletions, Densities, Details, Events, Examples, Fields, Filters, Flashes,
60
- Formats, Inputs, Kinds, Limits, Links, Maps, Names, Navigation, Parents,
61
- Pictures, Previews, References, Refreshes, Routing, Resources, Rows, Searches,
62
- Shapes, Shortcuts, Sidebars, Sorts, Spans, Tabs, Themes, Times, Values,
63
- Warnings, Weeks, Zones
61
+ Deletions, Densities, Details, Events, Examples, Fields, FilterMenus, Filters,
62
+ Flashes, Formats, Inputs, Kinds, Limits, Links, Maps, Names, Navigation,
63
+ Parents, Pictures, Previews, Reaches, References, Refreshes, Routing,
64
+ Resources, Rows, Searches, Shapes, Shortcuts, Sidebars, Sorts, Spans, Tabs,
65
+ Themes, Times, Values, Warnings, Weeks, Zones
64
66
 
65
67
  # The grid a record's own two pages lay an attribute out in: two columns on a large
66
68
  # viewport, and the same padding on both, so a value and the field that edits it sit
@@ -11,7 +11,7 @@ module Recourse
11
11
  # big to list. The model's own columns come first, before the menus that name
12
12
  # other tables.
13
13
  def filter_fields
14
- enum_filter_fields.merge(boolean_filter_fields).merge reference_filter_fields
14
+ enum_filter_fields + boolean_filter_fields + reference_filter_fields
15
15
  end
16
16
 
17
17
  private
@@ -27,7 +27,7 @@ module Recourse
27
27
  # Every one of the three comes to the same shape, and `_in` is what lets a
28
28
  # request tick more than one of the values a menu offers.
29
29
  def filters_for(names)
30
- names.index_with({}).transform_keys { |name| "#{name}_in" }
30
+ names.map { |name| "#{name}_in" }
31
31
  end
32
32
 
33
33
  # One per enum: a dozen words a column admits are a menu whatever else is on the
@@ -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 = '7.6.1'
3
+ VERSION = '7.7.0'
4
4
  end
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: 7.6.1
4
+ version: 7.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - claudiob
@@ -211,6 +211,7 @@ files:
211
211
  - lib/recourse/helpers/events.rb
212
212
  - lib/recourse/helpers/examples.rb
213
213
  - lib/recourse/helpers/fields.rb
214
+ - lib/recourse/helpers/filter_menus.rb
214
215
  - lib/recourse/helpers/filters.rb
215
216
  - lib/recourse/helpers/flashes.rb
216
217
  - lib/recourse/helpers/formats.rb
@@ -225,6 +226,7 @@ files:
225
226
  - lib/recourse/helpers/pictures.rb
226
227
  - lib/recourse/helpers/positions.rb
227
228
  - lib/recourse/helpers/previews.rb
229
+ - lib/recourse/helpers/reaches.rb
228
230
  - lib/recourse/helpers/references.rb
229
231
  - lib/recourse/helpers/refreshes.rb
230
232
  - lib/recourse/helpers/resources.rb