crud_components 0.1.0 → 0.2.1

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +15 -11
  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 +25 -53
  13. data/docs/filtering.md +167 -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/builder.rb +3 -3
  18. data/lib/crud_components/dynamic_column.rb +11 -3
  19. data/lib/crud_components/fields/attachment_field.rb +23 -1
  20. data/lib/crud_components/fields/base.rb +71 -11
  21. data/lib/crud_components/fields/belongs_to_field.rb +38 -11
  22. data/lib/crud_components/fields/boolean_field.rb +2 -2
  23. data/lib/crud_components/fields/date_field.rb +2 -2
  24. data/lib/crud_components/fields/enum_field.rb +4 -4
  25. data/lib/crud_components/fields/has_many_field.rb +22 -2
  26. data/lib/crud_components/fields/numeric_field.rb +2 -2
  27. data/lib/crud_components/fields/path_field.rb +6 -6
  28. data/lib/crud_components/fields/string_field.rb +3 -3
  29. data/lib/crud_components/helpers.rb +20 -6
  30. data/lib/crud_components/like_spec.rb +33 -34
  31. data/lib/crud_components/presenters/collection.rb +17 -8
  32. data/lib/crud_components/presenters/filter.rb +27 -3
  33. data/lib/crud_components/query.rb +45 -6
  34. data/lib/crud_components/structure.rb +12 -2
  35. data/lib/crud_components/typed_filter.rb +113 -0
  36. data/lib/crud_components/version.rb +1 -1
  37. data/lib/crud_components.rb +8 -0
  38. metadata +8 -1
data/docs/security.md CHANGED
@@ -125,15 +125,17 @@ The guarantees, each backed by a test:
125
125
 
126
126
  ## `?q=` search and permissions
127
127
 
128
- `search_in` is the model's **text identity**, used model-globally (it powers `?q=`, the
129
- belongs_to text fallback, and delegated specs). Two things follow:
128
+ `search_in` is the model's **text identity**, powering `?q=`. Three things follow:
130
129
 
131
130
  - A **declared, permission-gated** column (`attribute :notes, if: :manage`) is dropped from
132
131
  the search spec for a user who can't see it — `?q=` upholds "hidden everywhere".
133
- - An **undeclared** column in the zero-config default spec (all string/text columns) is
134
- searched model-globally by design. If a model has a sensitive string column you don't
135
- want reachable via `?q=`, declare `search_in` explicitly with only the columns you want.
136
- The default is broad so zero-config search "just finds things"; narrowing it is the
137
- author's call.
132
+ - The **zero-config default is "search what you see"**: the index's own string/text columns
133
+ plus its associations' labels. A column you never display including a model's secret
134
+ columns (`encrypted_password`, `*_token`, `api_key`) is never searched. Declare
135
+ `search_in` to override (a narrower column list, or a block for full-text).
136
+ - An **association** reached by `?q=`, the belongs_to text fallback, or a spec naming it
137
+ (`filter :publisher`) matches the target's **label** only — never the target's other
138
+ columns. So filtering by a `belongs_to :user` can't probe `users.encrypted_password`,
139
+ even when `User` has no `crud_structure` of its own.
138
140
 
139
141
  See also: [Performance](performance.md) · [Views](views.md) · [Fields](fields.md) · [Forms](forms.md).
data/docs/views.md CHANGED
@@ -15,7 +15,7 @@ this doc.
15
15
  crud_collection(records, fieldset: nil, layout: :table, query: :auto, param_prefix: nil,
16
16
  actions: true, group_by: nil, extra_columns: nil, picker: false, picked_columns: :auto)
17
17
  crud_record(record, fieldset: nil, actions: true, layout: :record, picked_columns: :auto)
18
- crud_filter(model, fieldset: nil, query: nil, param_prefix: nil, layout: :filter)
18
+ crud_filter(model, fieldset: nil, query: nil, param_prefix: nil, extra_columns: nil, sort: false, layout: :filter)
19
19
  crud_form(record, fieldset: nil, action: nil, url: nil, method: nil, layout: :form) # see forms.md
20
20
  crud_actions(record_or_model, fieldset: nil) # a record → row actions; a model class → collection actions
21
21
  ```
@@ -95,6 +95,10 @@ fieldset :index, %i[cover title price], filters: %i[genre published_on]
95
95
  `filters:` extends the fieldset's filterable set (sorting stays strictly visible fields);
96
96
  `crud_filter` renders all of them.
97
97
 
98
+ The standalone `crud_filter` form takes `extra_columns:` (for dynamic-column filters) and
99
+ `sort: true` (a sort picker for headerless surfaces) — see
100
+ [Filtering → the standalone form](filtering.md#the-standalone-filter-form).
101
+
98
102
  ### Layout is a separate axis
99
103
 
100
104
  ```erb
@@ -365,7 +369,9 @@ the query into your own hands — the explicit form of what the helper does auto
365
369
 
366
370
  Everything stays an ActiveRecord relation, so any paginator and any pre-existing scope
367
371
  compose. The manual query is also how you get the filtered relation for counts, CSV
368
- exports, or charts.
372
+ exports, or charts. To permit the query's params yourself or rebuild filter-preserving
373
+ links by hand, the query exposes the params it reads — see
374
+ [the params a query reads](filtering.md#the-params-a-query-reads).
369
375
 
370
376
  **The footer pager renders itself** when the relation you pass is already paginated —
371
377
  i.e. you called `.page` and it decorates the relation (kaminari, will_paginate). The gem
@@ -34,7 +34,7 @@ module CrudComponents
34
34
  # How a record is titled (links, headings). Give a method name or a block.
35
35
  # @param method [Symbol, nil] a method on the record returning its label.
36
36
  # @param preload [Array<Symbol>, Symbol, nil] associations the label reaches
37
- # into (`label :full_title, preload: %i[customer training]`). They're
37
+ # into (`label :display_title, preload: %i[publisher authors]`). They're
38
38
  # eager-loaded automatically whenever this model is shown as another
39
39
  # model's association column — declare once, no N+1 anywhere.
40
40
  # @yield [record] computes the label; receives the record.
@@ -51,7 +51,7 @@ module CrudComponents
51
51
  # Associations to eager-load whenever this model is rendered (as a row or as
52
52
  # another model's association cell) — for label/render dependencies the gem
53
53
  # can't infer. Additive with `label …, preload:`; declare more than once to
54
- # accumulate. e.g. `preload :customer, :training`.
54
+ # accumulate. e.g. `preload :publisher, :authors`.
55
55
  # @param names [Array<Symbol>] association names (nested hashes allowed).
56
56
  # @return [void]
57
57
  def preload(*names)
@@ -154,7 +154,7 @@ module CrudComponents
154
154
  private
155
155
 
156
156
  # Normalize a preload value to an array of includes-specs, leaving a nested
157
- # hash (`{ customer: :company }`) intact (Array() would split it).
157
+ # hash (`{ publisher: :books }`) intact (Array() would split it).
158
158
  def preload_list(value)
159
159
  case value
160
160
  when nil then []
@@ -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_column_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,17 +60,17 @@ 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
 
55
71
  # Load the association, nesting the target's identity_preloads (its label's
56
72
  # own association deps) plus any per-column `preload:` so the target's
57
- # label never N+1s. e.g. { order: %i[customer training] }.
73
+ # label never N+1s. e.g. { book: %i[publisher authors] }.
58
74
  # A polymorphic belongs_to has no single target class, so we can't nest its
59
75
  # label's preloads — just preload the association itself (Rails groups it by
60
76
  # type); the cell still renders each record's label and links it at runtime.
@@ -80,11 +96,22 @@ 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
+ target_structure.label_column_name
106
+ end
107
+
108
+ # Free text matches the target's label only — the name shown in the cell.
109
+ # A block/computed label has no column to match, so there's no text filter.
83
110
  def like_subquery(scope, value)
84
- spec = target_structure.search_in_spec
85
- return nil if spec.nil? || spec.empty?
111
+ label = target_structure.label_column_name
112
+ return nil unless label
86
113
 
87
- scope.where(name => LikeSpec.apply(target.all, spec, value))
114
+ scope.where(name => LikeSpec.apply(target.all, [label], value))
88
115
  end
89
116
  end
90
117
  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_column_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_column_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
 
@@ -172,7 +186,7 @@ module CrudComponents
172
186
  end
173
187
 
174
188
  # The label for an associated record in an association column: a per-column
175
- # `label:` callable (`attribute :order, label: ->(o) { o.full_title(short: true) }`)
189
+ # `label:` callable (`attribute :publisher, label: ->(p) { p.display_title(short: true) }`)
176
190
  # when given, else the target's default {#crud_label}. Used by the
177
191
  # association / association_list renderers so a column can re-title the
178
192
  # associated record for its context while keeping the nil-safe link.