crud_components 0.1.0 → 0.2.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +13 -9
  4. data/RELEASING.md +14 -9
  5. data/app/views/crud_components/_filter.html.erb +20 -0
  6. data/app/views/crud_components/filters/_date.html.erb +5 -0
  7. data/app/views/crud_components/filters/_number.html.erb +6 -0
  8. data/app/views/crud_components/filters/_presence.html.erb +13 -0
  9. data/config/locales/crud_components.de.yml +7 -0
  10. data/config/locales/crud_components.en.yml +7 -0
  11. data/crud_components.gemspec +1 -0
  12. data/docs/fields.md +20 -48
  13. data/docs/filtering.md +166 -0
  14. data/docs/performance.md +1 -1
  15. data/docs/security.md +9 -7
  16. data/docs/views.md +8 -2
  17. data/lib/crud_components/dynamic_column.rb +11 -3
  18. data/lib/crud_components/fields/attachment_field.rb +23 -1
  19. data/lib/crud_components/fields/base.rb +71 -11
  20. data/lib/crud_components/fields/belongs_to_field.rb +38 -10
  21. data/lib/crud_components/fields/boolean_field.rb +2 -2
  22. data/lib/crud_components/fields/date_field.rb +2 -2
  23. data/lib/crud_components/fields/enum_field.rb +4 -4
  24. data/lib/crud_components/fields/has_many_field.rb +22 -2
  25. data/lib/crud_components/fields/numeric_field.rb +2 -2
  26. data/lib/crud_components/fields/path_field.rb +6 -6
  27. data/lib/crud_components/fields/string_field.rb +3 -3
  28. data/lib/crud_components/helpers.rb +19 -5
  29. data/lib/crud_components/like_spec.rb +32 -33
  30. data/lib/crud_components/presenters/collection.rb +17 -8
  31. data/lib/crud_components/presenters/filter.rb +27 -3
  32. data/lib/crud_components/query.rb +45 -6
  33. data/lib/crud_components/structure.rb +5 -2
  34. data/lib/crud_components/typed_filter.rb +113 -0
  35. data/lib/crud_components/version.rb +1 -1
  36. data/lib/crud_components.rb +8 -0
  37. metadata +8 -1
@@ -12,13 +12,21 @@ module CrudComponents
12
12
  # PropertyValue.where(definition: prop, subject: records).index_by(&:subject_id)
13
13
  # },
14
14
  # sort: ->(scope, dir) { ... }, # optional — omit for display-only
15
- # filter: ->(scope, value) { ... }) { |record, loaded| loaded[record.id]&.value }
15
+ # filter: ->(scope, geq:, leq:) { ... }) { |record, loaded| loaded[record.id]&.value }
16
16
  #
17
17
  # The block is the value resolver: `|record|` or `|record, loaded|`, where
18
18
  # `loaded` is whatever `preload:` returned. It returns a plain value that the
19
19
  # `as:` renderer (or, with no `as:`, the value's type) displays — exactly like
20
- # a computed field. `filter:`/`sort:` are the same facet blocks the DSL takes;
21
- # supply them only when the data is reachable in SQL, otherwise the column is
20
+ # a computed field.
21
+ #
22
+ # `sort:` is the same facet block the DSL takes. `filter:` reaches SQL too, but
23
+ # the control it renders follows the column's type: a `filter:` block that
24
+ # declares keyword params (`geq:`/`leq:`, `eq:`, `contains:`) filters as the
25
+ # `as:` type does — a `:number` column gets a number range, `:date` a date range,
26
+ # `:boolean` a yes/no select. Override the filter type with `filter_as:` (and
27
+ # `filter_choices:` for a select) when it differs from `as:`. A positional
28
+ # `filter: ->(scope, value)` block stays a plain text filter. Supply `filter:`/
29
+ # `sort:` only when the data is reachable in SQL, otherwise the column is
22
30
  # display-only and never reaches the query layer.
23
31
  #
24
32
  # A dynamic column often *is* a domain object (a mail, a resource), so its
@@ -10,13 +10,35 @@ module CrudComponents
10
10
  end
11
11
 
12
12
  def eager_load
13
- [many? ? :"#{name}_attachments" : :"#{name}_attachment", *declared_preloads]
13
+ [attachment_reflection, *declared_preloads]
14
+ end
15
+
16
+ # ── filtering ──────────────────────────────────────────────────────────
17
+ # An attachment has no value to type into a box, but "has a cover / has no
18
+ # cover" is the natural question — so it filters by presence: a 3-state
19
+ # any/present/absent control that composes into the query as an EXISTS /
20
+ # NOT EXISTS over the backing *_attachment(s) association.
21
+ def derived_filterable? = true
22
+ def derived_filter_control = :presence
23
+
24
+ def apply_derived_filter(scope, value: nil, **)
25
+ case value
26
+ when CrudComponents::PRESENT_FILTER_VALUE then scope.where.associated(attachment_reflection)
27
+ when CrudComponents::ABSENT_FILTER_VALUE then scope.where.missing(attachment_reflection)
28
+ else scope
29
+ end
14
30
  end
15
31
 
16
32
  # ── forms ────────────────────────────────────────────────────────────
17
33
  def default_editable? = true
18
34
  def form_control = :file
19
35
  def permit_param = many? ? { name => [] } : name
36
+
37
+ private
38
+
39
+ # The has_one_attached / has_many_attached association the eager-load and the
40
+ # presence filter join through.
41
+ def attachment_reflection = many? ? :"#{name}_attachments" : :"#{name}_attachment"
20
42
  end
21
43
  end
22
44
  end
@@ -56,6 +56,14 @@ module CrudComponents
56
56
  model.columns_hash[name.to_s]
57
57
  end
58
58
 
59
+ # How this field feeds the default ?q= ("search what you see"): its own
60
+ # string/text column, or nil for fields with no free-text column behind
61
+ # them (numbers, dates, enums, attachments, computed). Associations
62
+ # override this to contribute their target's label.
63
+ def search_spec_entry
64
+ name if column && %i[string text].include?(column.type)
65
+ end
66
+
59
67
  # Whether the backing column permits NULL — gates the "not set" filter
60
68
  # choice and the 3-state form control for nullable boolean/enum fields.
61
69
  def nullable?
@@ -85,7 +93,7 @@ module CrudComponents
85
93
  end
86
94
 
87
95
  def renderer_options
88
- options.except(:as, :if, :form_as, :label, :header, :header_actions)
96
+ options.except(:as, :if, :form_as, :label, :header, :header_actions, :filter_as, :filter_choices)
89
97
  end
90
98
 
91
99
  # ── permissions ──────────────────────────────────────────────────────
@@ -97,7 +105,7 @@ module CrudComponents
97
105
  def filterable?
98
106
  return false if facets[:filter] == false
99
107
  return false if CrudComponents::RESERVED_PARAMS.include?(name.to_s)
100
- return true if filter_facet
108
+ return true if typed_filter || filter_facet
101
109
 
102
110
  derived_filterable?
103
111
  end
@@ -107,6 +115,16 @@ module CrudComponents
107
115
  facets[:filter].is_a?(Hash) || facets[:filter].is_a?(Symbol) ? facets[:filter] : nil
108
116
  end
109
117
 
118
+ # The internal {TypedFilter} for a `filter:` block that declares keyword params
119
+ # (eq:/geq:/leq:/contains:) — its value type comes from `filter_as:` or `as:`,
120
+ # so it renders the matching control and receives cast values. A positional
121
+ # `->(scope, value)` block has none (plain text filter); nil for everyone else.
122
+ def typed_filter
123
+ return @typed_filter if defined?(@typed_filter)
124
+
125
+ @typed_filter = build_typed_filter
126
+ end
127
+
110
128
  def derived_filterable?
111
129
  false
112
130
  end
@@ -114,6 +132,8 @@ module CrudComponents
114
132
  # Which filter control partial to render: :text, :select, :boolean,
115
133
  # :number_range or :date_range.
116
134
  def filter_control
135
+ return typed_filter.control if typed_filter
136
+
117
137
  filter_facet ? :text : derived_filter_control
118
138
  end
119
139
 
@@ -121,22 +141,26 @@ module CrudComponents
121
141
  :text
122
142
  end
123
143
 
124
- def filter_choices(_query = nil)
125
- nil
144
+ def filter_choices(query = nil)
145
+ typed_filter&.filter_choices(query)
126
146
  end
127
147
 
128
148
  def range_filter?
129
149
  filter_control == :number_range || filter_control == :date_range
130
150
  end
131
151
 
132
- # `exact`, `geq`, `leq` are the raw param values (Strings or nil).
133
- def apply_filter(scope, exact: nil, geq: nil, leq: nil)
134
- if filter_facet
135
- return scope unless exact
152
+ # The raw param values (Strings or nil): `value` is the bare `?field=`, `geq`
153
+ # and `leq` the range bounds. How a field reads `value` is up to it — an
154
+ # exact match for a number/enum, a substring for text.
155
+ def apply_filter(scope, value: nil, geq: nil, leq: nil)
156
+ if typed_filter
157
+ typed_filter.apply(scope, value:, geq:, leq:)
158
+ elsif filter_facet
159
+ return scope unless value
136
160
 
137
- apply_filter_facet(scope, exact)
161
+ apply_filter_facet(scope, value)
138
162
  else
139
- apply_derived_filter(scope, exact:, geq:, leq:)
163
+ apply_derived_filter(scope, value:, geq:, leq:)
140
164
  end
141
165
  end
142
166
 
@@ -170,9 +194,15 @@ module CrudComponents
170
194
  false
171
195
  end
172
196
 
197
+ # An explicit `?sort=` must win over any order a prior stage set (a search
198
+ # backend's relevance rank, a default scope). The Symbol/derived branches
199
+ # already `.reorder`; a Proc facet is handed a scope whose prior order is
200
+ # cleared, so a block using the obvious `.order(...)` overrides the rank too
201
+ # rather than appending to it (see issue #23). A block may still `.reorder`
202
+ # itself — that composes fine on an already-cleared scope.
173
203
  def apply_sort(scope, dir)
174
204
  case (facet = sort_facet)
175
- when Proc then facet.call(scope, dir)
205
+ when Proc then facet.call(scope.reorder(nil), dir)
176
206
  when Symbol then scope.reorder(model.arel_table[facet].public_send(dir))
177
207
  else scope.reorder(model.arel_table[name].public_send(dir))
178
208
  end
@@ -248,6 +278,36 @@ module CrudComponents
248
278
 
249
279
  private
250
280
 
281
+ # Maps a render type (`as:` / `filter_as:`) to a filter value type. An
282
+ # unmapped value (a custom renderer) falls back to text.
283
+ RENDER_TO_FILTER_TYPE = {
284
+ number: :numeric, numeric: :numeric,
285
+ date: :date, datetime: :date,
286
+ boolean: :boolean,
287
+ enum: :select, select: :select,
288
+ string: :text, text: :text
289
+ }.freeze
290
+
291
+ def build_typed_filter
292
+ facet = facets[:filter]
293
+ return facet if facet.is_a?(CrudComponents::TypedFilter) # already built (escape hatch)
294
+ return nil unless facet.is_a?(Proc) && keyword_filter_block?(facet)
295
+
296
+ CrudComponents::TypedFilter.new(filter_type, facet, choices: options[:filter_choices])
297
+ end
298
+
299
+ # A filter block opts into a typed control by declaring keyword params
300
+ # (eq:/geq:/leq:/contains:); a positional `->(scope, value)` stays plain text.
301
+ def keyword_filter_block?(block)
302
+ block.parameters.any? { |kind, _| %i[key keyreq keyrest].include?(kind) }
303
+ end
304
+
305
+ # The filter's value type: `filter_as:` if given, else inferred from the
306
+ # render type `as:`, else text.
307
+ def filter_type
308
+ RENDER_TO_FILTER_TYPE.fetch(options[:filter_as] || options[:as], :text)
309
+ end
310
+
251
311
  def arel_column
252
312
  model.arel_table[name]
253
313
  end
@@ -2,8 +2,9 @@ module CrudComponents
2
2
  module Fields
3
3
  # belongs_to / has_one: nil-safe link via the target's label. The filter
4
4
  # (belongs_to only) accepts both the target's identify_by value (what the
5
- # select submits) and free text matched against the target's search_in
6
- # one param, two OR-combined parameterized subqueries.
5
+ # select submits) and free text matched against the target's label the
6
+ # name shown in the cell — one param, two OR-combined parameterized
7
+ # subqueries.
7
8
  class BelongsToField < Base
8
9
  def default_renderer = :association
9
10
 
@@ -23,11 +24,26 @@ module CrudComponents
23
24
  Structure.for(target)
24
25
  end
25
26
 
27
+ # Default ?q= reaches the target's label (the name shown in the cell).
28
+ # Skipped for polymorphic (no single target) or a block/columnless label.
29
+ def search_spec_entry
30
+ name if !reflection.polymorphic? && target_structure.label_field_name
31
+ end
32
+
26
33
  def derived_filterable?
27
34
  reflection.belongs_to? && !reflection.polymorphic?
28
35
  end
29
36
 
30
- def derived_sortable? = false
37
+ # Sortable by the column behind the target's label — the name shown in the
38
+ # cell — reached with a join. A block label, or a label that isn't a real
39
+ # column, has no SQL ordering, so the column isn't sortable then.
40
+ def derived_sortable? = sort_column.present?
41
+
42
+ def apply_sort(scope, dir)
43
+ return super if sort_facet
44
+
45
+ scope.left_joins(name).reorder(target.arel_table[sort_column].public_send(dir))
46
+ end
31
47
 
32
48
  # select (a dropdown of all targets) below `select_limit` rows, else free
33
49
  # text. Counted per render, not memoized: the field instance lives on the
@@ -44,11 +60,11 @@ module CrudComponents
44
60
  .sort_by(&:first)
45
61
  end
46
62
 
47
- def apply_derived_filter(scope, exact: nil, **)
48
- return scope unless exact
63
+ def apply_derived_filter(scope, value: nil, **)
64
+ return scope unless value
49
65
 
50
- identified = scope.where(name => target.where(target_structure.identify_by => exact))
51
- searched = like_subquery(scope, exact)
66
+ identified = scope.where(name => target.where(target_structure.identify_by => value))
67
+ searched = like_subquery(scope, value)
52
68
  searched ? identified.or(searched) : identified
53
69
  end
54
70
 
@@ -80,11 +96,23 @@ module CrudComponents
80
96
 
81
97
  private
82
98
 
99
+ # The target column to ORDER BY: the field behind its label when that's a
100
+ # real column, else nil (a block label or computed attribute can't be sorted
101
+ # in SQL). Polymorphic belongs_to has no single target, so never sortable.
102
+ def sort_column
103
+ return nil if reflection.polymorphic?
104
+
105
+ col = target_structure.label_field_name
106
+ col if col && target.column_names.include?(col.to_s)
107
+ end
108
+
109
+ # Free text matches the target's label only — the name shown in the cell.
110
+ # A block/computed label has no column to match, so there's no text filter.
83
111
  def like_subquery(scope, value)
84
- spec = target_structure.search_in_spec
85
- return nil if spec.nil? || spec.empty?
112
+ label = target_structure.label_field_name
113
+ return nil unless label
86
114
 
87
- scope.where(name => LikeSpec.apply(target.all, spec, value))
115
+ scope.where(name => LikeSpec.apply(target.all, [label], value))
88
116
  end
89
117
  end
90
118
  end
@@ -18,8 +18,8 @@ module CrudComponents
18
18
  # A nullable column offers a "not set" (IS NULL) choice in the filter.
19
19
  def filter_includes_null? = nullable?
20
20
 
21
- def apply_derived_filter(scope, exact: nil, **)
22
- case exact&.downcase
21
+ def apply_derived_filter(scope, value: nil, **)
22
+ case value&.downcase
23
23
  when *TRUE_VALUES then scope.where(name => true)
24
24
  when *FALSE_VALUES then scope.where(name => false)
25
25
  when CrudComponents::NULL_FILTER_VALUE then nullable? ? scope.where(name => nil) : scope
@@ -14,8 +14,8 @@ module CrudComponents
14
14
  def default_editable? = !NON_EDITABLE_COLUMNS.include?(name.to_s)
15
15
  def form_control = datetime? ? :datetime : :date
16
16
 
17
- def apply_derived_filter(scope, exact: nil, geq: nil, leq: nil)
18
- if (d = cast(exact)) then scope = apply_day(scope, d) end
17
+ def apply_derived_filter(scope, value: nil, geq: nil, leq: nil)
18
+ if (d = cast(value)) then scope = apply_day(scope, d) end
19
19
  if (d = cast(geq)) then scope = scope.where(arel_column.gteq(lower_bound(d))) end
20
20
  if (d = cast(leq)) then scope = scope.where(arel_column.lteq(upper_bound(d))) end
21
21
  scope
@@ -29,11 +29,11 @@ module CrudComponents
29
29
  # A nullable column offers a "not set" (IS NULL) choice in the filter.
30
30
  def filter_includes_null? = nullable?
31
31
 
32
- def apply_derived_filter(scope, exact: nil, **)
33
- return scope.where(name => nil) if exact == CrudComponents::NULL_FILTER_VALUE && nullable?
34
- return scope unless exact && enum_keys.include?(exact)
32
+ def apply_derived_filter(scope, value: nil, **)
33
+ return scope.where(name => nil) if value == CrudComponents::NULL_FILTER_VALUE && nullable?
34
+ return scope unless value && enum_keys.include?(value)
35
35
 
36
- scope.where(name => exact)
36
+ scope.where(name => value)
37
37
  end
38
38
  end
39
39
  end
@@ -1,7 +1,7 @@
1
1
  module CrudComponents
2
2
  module Fields
3
- # has_many / habtm: truncated list of links ("a, b +3 more"). No derived
4
- # filter or sort; opt in with `filter like: :assoc` (delegation).
3
+ # has_many / habtm: truncated list of links ("a, b +3 more"). Filters by the
4
+ # children's label (the names shown in the list); no derived sort.
5
5
  class HasManyField < Base
6
6
  def default_renderer = :association_list
7
7
 
@@ -45,6 +45,26 @@ module CrudComponents
45
45
  end
46
46
 
47
47
  def target_structure = Structure.for(target)
48
+
49
+ # Default ?q= reaches the children's label (the names shown in the list).
50
+ # Skipped when the target's label is a block/columnless.
51
+ def search_spec_entry
52
+ name if target_structure.label_field_name
53
+ end
54
+
55
+ # ── filter ─────────────────────────────────────────────────────────────
56
+ # "Filter what you see": a free-text contains-match against the children's
57
+ # label — the names shown in the list — so an owner matches when at least
58
+ # one of its children does. Skipped when the target's label is a
59
+ # block/columnless (no single column to match). Mirrors belongs_to, which
60
+ # filters by its target's label the same way.
61
+ def derived_filterable? = target_structure.label_field_name.present?
62
+
63
+ def apply_derived_filter(scope, value: nil, **)
64
+ return scope unless value
65
+
66
+ LikeSpec.apply(scope, name, value)
67
+ end
48
68
  end
49
69
  end
50
70
  end
@@ -9,8 +9,8 @@ module CrudComponents
9
9
  def default_editable? = !NON_EDITABLE_COLUMNS.include?(name.to_s)
10
10
  def form_control = :number
11
11
 
12
- def apply_derived_filter(scope, exact: nil, geq: nil, leq: nil)
13
- if (v = cast(exact)) then scope = scope.where(name => v) end
12
+ def apply_derived_filter(scope, value: nil, geq: nil, leq: nil)
13
+ if (v = cast(value)) then scope = scope.where(name => v) end
14
14
  if (v = cast(geq)) then scope = scope.where(arel_column.gteq(v)) end
15
15
  if (v = cast(leq)) then scope = scope.where(arel_column.lteq(v)) end
16
16
  scope
@@ -159,12 +159,12 @@ module CrudComponents
159
159
  delegating? && target_field.respond_to?(:human_value) ? target_field.human_value(value) : value
160
160
  end
161
161
 
162
- def apply_filter(scope, exact: nil, geq: nil, leq: nil)
162
+ def apply_filter(scope, value: nil, geq: nil, leq: nil)
163
163
  return super if filter_facet # an author-supplied facet wins
164
- return delegate_filter(scope, exact: exact, geq: geq, leq: leq) if delegating?
165
- return scope unless exact
164
+ return delegate_filter(scope, value: value, geq: geq, leq: leq) if delegating?
165
+ return scope unless value
166
166
 
167
- LikeSpec.apply(scope, filter_spec, exact)
167
+ LikeSpec.apply(scope, filter_spec, value)
168
168
  end
169
169
 
170
170
  # ── sorting: single-valued paths only ────────────────────────────────────
@@ -233,8 +233,8 @@ module CrudComponents
233
233
  # target model on its own table (so `where(col => …)` binds correctly), then
234
234
  # constrain the root through the association chain — an IN subquery, no JOIN,
235
235
  # so it can't multiply rows.
236
- def delegate_filter(scope, exact:, geq:, leq:)
237
- matched = target_field.apply_filter(target_model.all, exact: exact, geq: geq, leq: leq)
236
+ def delegate_filter(scope, value:, geq:, leq:)
237
+ matched = target_field.apply_filter(target_model.all, value: value, geq: geq, leq: leq)
238
238
  constrained = reflections.reverse.reduce(matched) do |sub, ref|
239
239
  ref.active_record.where(ref.name => sub)
240
240
  end
@@ -30,11 +30,11 @@ module CrudComponents
30
30
  def default_editable? = !NON_EDITABLE_COLUMNS.include?(name.to_s)
31
31
  def form_control = :string
32
32
 
33
- def apply_derived_filter(scope, exact: nil, **)
34
- return scope unless exact
33
+ def apply_derived_filter(scope, value: nil, **)
34
+ return scope unless value
35
35
 
36
36
  # explicit escape char: backslash is not SQLite's default
37
- scope.where(arel_column.matches(like_pattern(exact), '\\'))
37
+ scope.where(arel_column.matches(like_pattern(value), '\\'))
38
38
  end
39
39
  end
40
40
  end
@@ -20,6 +20,10 @@ module CrudComponents
20
20
  # auto collections can share one page.
21
21
  # @param actions [Boolean] render the actions column + toolbar (false to place
22
22
  # them yourself with {#crud_actions}).
23
+ # @param search_bar [Boolean] render the toolbar's `?q=` search box (default
24
+ # true). Pass false to drop just this collection's box — e.g. when a
25
+ # page-level search already covers it. The box only; the model stays
26
+ # searchable elsewhere and an explicit `?q=` still applies in `:auto` mode.
23
27
  # @param group_by [Symbol, nil] a column, belongs_to or enum to group rows
24
28
  # under collapsible headers.
25
29
  # @param extra_columns [Array<CrudComponents::DynamicColumn>, nil] user-defined
@@ -38,10 +42,12 @@ module CrudComponents
38
42
  # reorder, never reveal a column the `if:` gate forbids.
39
43
  # @return [ActiveSupport::SafeBuffer] the rendered HTML.
40
44
  def crud_collection(records, fieldset: nil, layout: :table, query: :auto, param_prefix: nil,
41
- actions: true, group_by: nil, extra_columns: nil, picker: false, picked_columns: :auto)
45
+ actions: true, search_bar: true, group_by: nil, extra_columns: nil,
46
+ picker: false, picked_columns: :auto)
42
47
  presenter = Presenters::Collection.new(view: self, records: records, fieldset: fieldset,
43
48
  query: query, layout: layout, param_prefix: param_prefix,
44
- actions: actions, group_by: group_by, extra_columns: extra_columns,
49
+ actions: actions, search_bar: search_bar, group_by: group_by,
50
+ extra_columns: extra_columns,
45
51
  picker: picker, picked_columns: picked_columns)
46
52
  render "crud_components/layouts/#{presenter.layout}", collection: presenter
47
53
  end
@@ -111,11 +117,19 @@ module CrudComponents
111
117
  # @param query [CrudComponents::Query, nil] reuse an existing query's values;
112
118
  # nil reads the request params.
113
119
  # @param param_prefix [Symbol, nil] namespaces the form's params.
120
+ # @param extra_columns [Array<CrudComponents::DynamicColumn>, nil] dynamic columns
121
+ # (the same you'd hand {#crud_collection}) whose filters should appear in the
122
+ # form — so a card/list surface gets its user-defined-property filters without
123
+ # hand-building a {Query}. Ignored when you pass a prebuilt `query:` (it already
124
+ # carries its own `extra_fields:`).
125
+ # @param sort [Boolean] also render a sort field/direction picker (default false).
126
+ # For a **headerless** surface (a card grid / list with no `<th>` sort links)
127
+ # this is the way to choose `?sort=&dir=`; a table carries the links itself.
114
128
  # @param layout [Symbol] the partial under `crud_components/` (`:filter` ships).
115
129
  # @return [ActiveSupport::SafeBuffer] the rendered HTML.
116
- def crud_filter(model, fieldset: nil, query: nil, param_prefix: nil, layout: :filter)
117
- presenter = Presenters::Filter.new(view: self, model: model, fieldset: fieldset,
118
- query: query, param_prefix: param_prefix)
130
+ def crud_filter(model, fieldset: nil, query: nil, param_prefix: nil, extra_columns: nil, sort: false, layout: :filter)
131
+ presenter = Presenters::Filter.new(view: self, model: model, fieldset: fieldset, query: query,
132
+ param_prefix: param_prefix, extra_columns: extra_columns, sort: sort)
119
133
  render "crud_components/#{layout}", filter: presenter
120
134
  end
121
135
 
@@ -5,17 +5,12 @@ module CrudComponents
5
5
  # :title own column
6
6
  # %i[title subtitle] several own columns, OR-combined
7
7
  # { authors: %i[name email] } join, explicit columns
8
- # :publisher join, delegate to Publisher's search_in
9
- # { user: :address } nested join, delegate to Address
8
+ # :publisher join, search Publisher's label (what you see)
9
+ # { user: :address } nested join, search Address's label
10
10
  #
11
11
  # Specs never contain SQL strings; conditions are built through Arel with
12
12
  # LIKE wildcards escaped, so they are parameterized end to end.
13
13
  module LikeSpec
14
- # Only *delegation* hops (an association name resolved through the target's
15
- # search_in) can form a cycle; explicit nesting is bounded by the literal
16
- # spec. So the guard counts delegations only.
17
- MAX_DELEGATIONS = 5
18
-
19
14
  module_function
20
15
 
21
16
  def apply(scope, spec, value)
@@ -29,8 +24,14 @@ module CrudComponents
29
24
 
30
25
  return scope.where(condition) if joins.empty?
31
26
 
32
- # distinct only matters once a join can multiply rows
33
- scope.left_joins(joins).where(condition).distinct
27
+ # A join can multiply rows, so the match has to be de-duplicated. We do it
28
+ # with an id subquery rather than SELECT DISTINCT: DISTINCT compares every
29
+ # selected column, which Postgres can't do for a json (or other
30
+ # non-comparable) column the scope happens to select — it raises "could not
31
+ # identify an equality operator for type json". The subquery sidesteps that
32
+ # and keeps the join (and its rows) out of the outer query entirely.
33
+ key = model.arel_table[model.primary_key]
34
+ scope.where(key.in(model.left_joins(joins).where(condition).select(key).arel))
34
35
  end
35
36
 
36
37
  Entry = Struct.new(:path, :klass, :column) do
@@ -47,19 +48,16 @@ module CrudComponents
47
48
  end
48
49
  end
49
50
 
50
- # Resolves a spec into flat [path, klass, column] entries, expanding
51
- # association names without columns through the target's search_in spec.
52
- # `delegations` counts only delegation hops (the cycle risk).
53
- def expand(model, spec, path = [], delegations = 0)
54
- if delegations > MAX_DELEGATIONS
55
- raise DefinitionError, "search_in/like delegation more than #{MAX_DELEGATIONS} levels deep " \
56
- "starting at #{model} — most likely a delegation cycle"
57
- end
58
-
51
+ # Resolves a spec into flat [path, klass, column] entries. A bare
52
+ # association name resolves to the target's label column — the text you
53
+ # actually see in that association's cell so it can never reach a
54
+ # target's hidden columns (passwords, tokens, …) and there is no
55
+ # search_in chain to form a cycle.
56
+ def expand(model, spec, path = [])
59
57
  Array.wrap(spec).flat_map do |item|
60
58
  case item
61
- when Symbol, String then expand_name(model, item.to_sym, path, delegations)
62
- when Hash then item.flat_map { |assoc, sub| expand_assoc(model, assoc.to_sym, sub, path, delegations) }
59
+ when Symbol, String then expand_name(model, item.to_sym, path)
60
+ when Hash then item.flat_map { |assoc, sub| expand_assoc(model, assoc.to_sym, sub, path) }
63
61
  else
64
62
  raise DefinitionError, "invalid like-spec element #{item.inspect} for #{model} — " \
65
63
  'use column symbols, association symbols, or { assoc => columns } hashes'
@@ -67,38 +65,39 @@ module CrudComponents
67
65
  end
68
66
  end
69
67
 
70
- def expand_name(model, name, path, delegations)
68
+ def expand_name(model, name, path)
71
69
  if model.columns_hash.key?(name.to_s)
72
70
  [Entry.new(path, model, name)]
73
71
  elsif (reflection = model.reflect_on_association(name))
74
- delegate(model, reflection, path, delegations)
72
+ delegate(model, reflection, path)
75
73
  else
76
74
  raise DefinitionError, "like-spec references '#{name}', which is neither a column nor " \
77
75
  "an association of #{model}"
78
76
  end
79
77
  end
80
78
 
81
- # Explicit nesting ({ assoc => columns }) — bounded by the spec, not a
82
- # cycle risk, so it does not count against the delegation limit.
83
- def expand_assoc(model, assoc, sub, path, delegations)
79
+ # Explicit nesting ({ assoc => columns }) — spell the target's columns out.
80
+ def expand_assoc(model, assoc, sub, path)
84
81
  reflection = model.reflect_on_association(assoc)
85
82
  raise DefinitionError, "like-spec references association '#{assoc}', " \
86
83
  "which #{model} does not have" unless reflection
87
84
 
88
- expand(reflection.klass, sub, path + [assoc], delegations)
85
+ expand(reflection.klass, sub, path + [assoc])
89
86
  end
90
87
 
91
- # Association name without columns: use the target model's search_in spec.
92
- def delegate(model, reflection, path, delegations)
88
+ # Association name without columns: search the target's label column — the
89
+ # name shown in its cell ("search what you see"). A block/computed label has
90
+ # no single column to match, so ask for the columns explicitly.
91
+ def delegate(model, reflection, path)
93
92
  target = reflection.klass
94
- target_spec = Structure.for(target).search_in_spec
95
- if target_spec.nil?
96
- raise DefinitionError, "cannot delegate like-spec to #{model}##{reflection.name}: " \
97
- "#{target}'s search_in is a custom block — spell the columns out, " \
93
+ label = Structure.for(target).label_field_name
94
+ if label.nil?
95
+ raise DefinitionError, "cannot search #{model}##{reflection.name} by label: " \
96
+ "#{target}'s label is a custom block, not a column — spell the columns out, " \
98
97
  "e.g. { #{reflection.name}: %i[...] }"
99
98
  end
100
99
 
101
- expand(target, target_spec, path + [reflection.name], delegations + 1)
100
+ [Entry.new(path + [reflection.name], target, label)]
102
101
  end
103
102
 
104
103
  def deep_merge(left, right)