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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/README.md +13 -9
- 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 +20 -48
- data/docs/filtering.md +166 -0
- data/docs/performance.md +1 -1
- data/docs/security.md +9 -7
- data/docs/views.md +8 -2
- 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 -10
- 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 +19 -5
- data/lib/crud_components/like_spec.rb +32 -33
- 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 +5 -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
|
@@ -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_field_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,11 +60,11 @@ 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
|
|
|
@@ -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
|
-
|
|
85
|
-
return nil
|
|
112
|
+
label = target_structure.label_field_name
|
|
113
|
+
return nil unless label
|
|
86
114
|
|
|
87
|
-
scope.where(name => LikeSpec.apply(target.all,
|
|
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,
|
|
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_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,
|
|
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
|
|
|
@@ -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,
|
|
9
|
-
# { user: :address } nested join,
|
|
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
|
-
#
|
|
33
|
-
|
|
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
|
|
51
|
-
# association
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
62
|
-
when Hash then item.flat_map { |assoc, sub| expand_assoc(model, assoc.to_sym, sub, path
|
|
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
|
|
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
|
|
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 }) —
|
|
82
|
-
|
|
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]
|
|
85
|
+
expand(reflection.klass, sub, path + [assoc])
|
|
89
86
|
end
|
|
90
87
|
|
|
91
|
-
# Association name without columns:
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
if
|
|
96
|
-
raise DefinitionError, "cannot
|
|
97
|
-
"#{target}'s
|
|
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
|
-
|
|
100
|
+
[Entry.new(path + [reflection.name], target, label)]
|
|
102
101
|
end
|
|
103
102
|
|
|
104
103
|
def deep_merge(left, right)
|