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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +15 -11
- data/RELEASING.md +14 -9
- data/app/views/crud_components/_filter.html.erb +20 -0
- data/app/views/crud_components/filters/_date.html.erb +5 -0
- data/app/views/crud_components/filters/_number.html.erb +6 -0
- data/app/views/crud_components/filters/_presence.html.erb +13 -0
- data/config/locales/crud_components.de.yml +7 -0
- data/config/locales/crud_components.en.yml +7 -0
- data/crud_components.gemspec +1 -0
- data/docs/fields.md +25 -53
- data/docs/filtering.md +167 -0
- data/docs/performance.md +1 -1
- data/docs/security.md +9 -7
- data/docs/views.md +8 -2
- data/lib/crud_components/builder.rb +3 -3
- data/lib/crud_components/dynamic_column.rb +11 -3
- data/lib/crud_components/fields/attachment_field.rb +23 -1
- data/lib/crud_components/fields/base.rb +71 -11
- data/lib/crud_components/fields/belongs_to_field.rb +38 -11
- data/lib/crud_components/fields/boolean_field.rb +2 -2
- data/lib/crud_components/fields/date_field.rb +2 -2
- data/lib/crud_components/fields/enum_field.rb +4 -4
- data/lib/crud_components/fields/has_many_field.rb +22 -2
- data/lib/crud_components/fields/numeric_field.rb +2 -2
- data/lib/crud_components/fields/path_field.rb +6 -6
- data/lib/crud_components/fields/string_field.rb +3 -3
- data/lib/crud_components/helpers.rb +20 -6
- data/lib/crud_components/like_spec.rb +33 -34
- data/lib/crud_components/presenters/collection.rb +17 -8
- data/lib/crud_components/presenters/filter.rb +27 -3
- data/lib/crud_components/query.rb +45 -6
- data/lib/crud_components/structure.rb +12 -2
- data/lib/crud_components/typed_filter.rb +113 -0
- data/lib/crud_components/version.rb +1 -1
- data/lib/crud_components.rb +8 -0
- 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**,
|
|
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
|
-
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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 :
|
|
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 :
|
|
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 (`{
|
|
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,
|
|
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.
|
|
21
|
-
#
|
|
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
|
-
[
|
|
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(
|
|
125
|
-
|
|
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
|
-
#
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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,
|
|
161
|
+
apply_filter_facet(scope, value)
|
|
138
162
|
else
|
|
139
|
-
apply_derived_filter(scope,
|
|
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
|
|
6
|
-
# one param, two OR-combined parameterized
|
|
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
|
-
|
|
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,
|
|
48
|
-
return scope unless
|
|
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 =>
|
|
51
|
-
searched = like_subquery(scope,
|
|
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. {
|
|
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
|
-
|
|
85
|
-
return nil
|
|
111
|
+
label = target_structure.label_column_name
|
|
112
|
+
return nil unless label
|
|
86
113
|
|
|
87
|
-
scope.where(name => LikeSpec.apply(target.all,
|
|
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,
|
|
22
|
-
case
|
|
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,
|
|
18
|
-
if (d = cast(
|
|
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,
|
|
33
|
-
return scope.where(name => nil) if
|
|
34
|
-
return scope unless
|
|
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 =>
|
|
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").
|
|
4
|
-
#
|
|
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,
|
|
13
|
-
if (v = cast(
|
|
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,
|
|
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,
|
|
165
|
-
return scope unless
|
|
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,
|
|
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,
|
|
237
|
-
matched = target_field.apply_filter(target_model.all,
|
|
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,
|
|
34
|
-
return scope unless
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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 :
|
|
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.
|