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
|
@@ -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
|
|
98
|
-
"e.g. { #{reflection.name}: %i[...] }"
|
|
93
|
+
label = Structure.for(target).label_column_name
|
|
94
|
+
if label.nil?
|
|
95
|
+
raise DefinitionError, "cannot search #{model}##{reflection.name} by label: " \
|
|
96
|
+
"#{target}'s label is not a column (custom block or computed method) — " \
|
|
97
|
+
"spell the columns out, 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)
|
|
@@ -15,7 +15,7 @@ module CrudComponents
|
|
|
15
15
|
attr_reader :model, :structure, :fieldset, :query, :layout, :param_prefix, :owner
|
|
16
16
|
|
|
17
17
|
def initialize(view:, records:, fieldset: nil, query: :auto, layout: :table,
|
|
18
|
-
param_prefix: nil, actions: true, group_by: nil,
|
|
18
|
+
param_prefix: nil, actions: true, search_bar: true, group_by: nil,
|
|
19
19
|
extra_columns: nil, picker: false, picked_columns: :auto)
|
|
20
20
|
super(view: view)
|
|
21
21
|
unless records.respond_to?(:klass)
|
|
@@ -31,6 +31,7 @@ module CrudComponents
|
|
|
31
31
|
@layout = layout
|
|
32
32
|
@param_prefix = param_prefix
|
|
33
33
|
@actions_enabled = actions
|
|
34
|
+
@search_bar_enabled = search_bar
|
|
34
35
|
# Two orthogonal column-picker knobs (see ColumnSelection): the gear is on
|
|
35
36
|
# iff `picker`; the selection comes from the param (`:auto`) or verbatim
|
|
36
37
|
# from the resolved Array — they never both read the param.
|
|
@@ -181,8 +182,13 @@ module CrudComponents
|
|
|
181
182
|
@filter_fields ||= static? ? [] : query.filter_fields
|
|
182
183
|
end
|
|
183
184
|
|
|
185
|
+
# Match by name, not object identity: a prebuilt Query carries its *own*
|
|
186
|
+
# DynamicField instances (from its extra_fields), while the collection built
|
|
187
|
+
# its own from extra_columns. Same column, different objects — `include?`
|
|
188
|
+
# would miss them and silently drop the inline filter/sort (issue #21).
|
|
189
|
+
# Declared fields are memoized on the Structure, so they share a name too.
|
|
184
190
|
def filterable_field?(field)
|
|
185
|
-
filter_fields.
|
|
191
|
+
filter_fields.any? { |f| f.name == field.name }
|
|
186
192
|
end
|
|
187
193
|
|
|
188
194
|
def filter_form_id
|
|
@@ -192,7 +198,7 @@ module CrudComponents
|
|
|
192
198
|
|
|
193
199
|
# ── header search (?q=) and reset ──────────────────────────────────────
|
|
194
200
|
def searchable?
|
|
195
|
-
!static? && query && query.searchable?
|
|
201
|
+
@search_bar_enabled && !static? && query && query.searchable?
|
|
196
202
|
end
|
|
197
203
|
|
|
198
204
|
def search_param_name
|
|
@@ -238,7 +244,7 @@ module CrudComponents
|
|
|
238
244
|
|
|
239
245
|
# ── sorting ──────────────────────────────────────────────────────────
|
|
240
246
|
def sortable_field?(field)
|
|
241
|
-
!static? && query && query.sortable_fields.
|
|
247
|
+
!static? && query && query.sortable_fields.any? { |f| f.name == field.name }
|
|
242
248
|
end
|
|
243
249
|
|
|
244
250
|
def sort_url(field)
|
|
@@ -355,11 +361,14 @@ module CrudComponents
|
|
|
355
361
|
@actions_enabled && (custom_actions_partial.present? || row_action_definitions.any?)
|
|
356
362
|
end
|
|
357
363
|
|
|
358
|
-
# The trailing column exists when
|
|
359
|
-
#
|
|
360
|
-
#
|
|
364
|
+
# The trailing column exists when something needs to live in it: row
|
|
365
|
+
# actions (in the body cells), the column picker (its gear in the header
|
|
366
|
+
# cell), or the inline filter row (its submit/reset button sits in this
|
|
367
|
+
# column). Without it a filterable table would render filter inputs with
|
|
368
|
+
# no button to apply them. Keeping it here also keeps the header, rows and
|
|
369
|
+
# width all in agreement.
|
|
361
370
|
def trailing_column?
|
|
362
|
-
actions_column? || column_picker?
|
|
371
|
+
actions_column? || column_picker? || filterable?
|
|
363
372
|
end
|
|
364
373
|
|
|
365
374
|
def custom_actions_partial
|
|
@@ -6,16 +6,21 @@ module CrudComponents
|
|
|
6
6
|
class Filter < Base
|
|
7
7
|
attr_reader :model, :structure, :query
|
|
8
8
|
|
|
9
|
-
def initialize(view:, model:, fieldset: nil, query: nil, param_prefix: nil)
|
|
9
|
+
def initialize(view:, model:, fieldset: nil, query: nil, param_prefix: nil, extra_columns: nil, sort: false)
|
|
10
10
|
super(view: view)
|
|
11
11
|
@model = model.is_a?(Class) ? model : model.klass
|
|
12
12
|
@structure = Structure.for(@model)
|
|
13
|
+
@sort = sort
|
|
14
|
+
# Dynamic columns become extra query fields, so their filters appear in the
|
|
15
|
+
# form exactly like a declared field's — mirrors crud_collection's
|
|
16
|
+
# extra_columns: (issue #22). A prebuilt query already carries its own.
|
|
17
|
+
dynamic_fields = Array(extra_columns).map { |c| c.to_field(@model) }
|
|
13
18
|
@query = if query.is_a?(Query)
|
|
14
19
|
query
|
|
15
20
|
else
|
|
16
21
|
Query.new(@model, view.request.query_parameters,
|
|
17
22
|
fieldset: @structure.fieldset(fieldset || :index),
|
|
18
|
-
ability: ability, param_prefix: param_prefix)
|
|
23
|
+
ability: ability, param_prefix: param_prefix, extra_fields: dynamic_fields)
|
|
19
24
|
end
|
|
20
25
|
end
|
|
21
26
|
|
|
@@ -27,10 +32,29 @@ module CrudComponents
|
|
|
27
32
|
def param_name(key) = query.param_name(key)
|
|
28
33
|
def value(key) = query.value(key)
|
|
29
34
|
|
|
30
|
-
#
|
|
35
|
+
# ── sorting (headerless surfaces) ──────────────────────────────────────
|
|
36
|
+
# Whether to render the sort picker: asked for, and there's something to
|
|
37
|
+
# sort by. A table carries its own header sort links, so this is opt-in.
|
|
38
|
+
def sort_control? = @sort && query.sortable_fields.any?
|
|
39
|
+
|
|
40
|
+
# The fields offered in the sort picker, as [human_name, name] pairs.
|
|
41
|
+
def sort_field_choices
|
|
42
|
+
query.sortable_fields.map { |f| [f.human_name, f.name.to_s] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# [field_name, direction] currently in effect, or [nil, 'asc'] when unsorted.
|
|
46
|
+
def sort_state
|
|
47
|
+
current, dir = query.sort_state
|
|
48
|
+
[current, dir || 'asc']
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Keep foreign params; our own controls resubmit themselves. Sort/dir are
|
|
52
|
+
# kept as hidden inputs (to preserve the current sort across an Apply) unless
|
|
53
|
+
# the sort picker renders them — then they'd duplicate, so we drop them.
|
|
31
54
|
def preserved_params
|
|
32
55
|
own = fields.flat_map { |f| [param_name(f.name.to_s), param_name("#{f.name}_geq"), param_name("#{f.name}_leq")] }
|
|
33
56
|
own += [param_name('q'), param_name('page'), param_name('per')]
|
|
57
|
+
own += [param_name('sort'), param_name('dir')] if sort_control?
|
|
34
58
|
view.request.query_parameters.reject { |key, _| own.include?(key) }
|
|
35
59
|
end
|
|
36
60
|
end
|
|
@@ -7,6 +7,10 @@ module CrudComponents
|
|
|
7
7
|
class Query
|
|
8
8
|
SORT_DIRECTIONS = %w[asc desc].freeze
|
|
9
9
|
|
|
10
|
+
# The reserved params the query itself reads. Pagination (page/per) is the
|
|
11
|
+
# host's — it lives in the controller, never here — so it is not listed.
|
|
12
|
+
RESERVED_PARAMS = %w[q sort dir].freeze
|
|
13
|
+
|
|
10
14
|
attr_reader :model, :structure, :fieldset, :param_prefix
|
|
11
15
|
|
|
12
16
|
def initialize(model, params, fieldset: nil, ability: nil, param_prefix: nil, extra_fields: [])
|
|
@@ -42,11 +46,38 @@ module CrudComponents
|
|
|
42
46
|
# Current value of a (logical, unprefixed) param — for filter controls.
|
|
43
47
|
def value(key) = param(key)
|
|
44
48
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
# The (prefixed) request-param names this query reads: every visible filter
|
|
50
|
+
# field's value and `_geq`/`_leq` bounds, plus the reserved q/sort/dir. The
|
|
51
|
+
# single source of truth for a strong-params permit list, so it can't drift
|
|
52
|
+
# from the columns:
|
|
53
|
+
# params.permit(*query.permitted_keys)
|
|
54
|
+
def permitted_keys
|
|
55
|
+
(filter_param_keys + RESERVED_PARAMS).map { |key| param_name(key) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The subset of the current request params this query reads, present values
|
|
59
|
+
# only, keyed by their real (prefixed) param names. Feed it to
|
|
60
|
+
# filter-preserving links (pagers, breadcrumbs, "reset" targets) instead of
|
|
61
|
+
# keeping a hand-maintained copy of the params.
|
|
62
|
+
def filter_params
|
|
63
|
+
permitted_keys.each_with_object({}) do |key, kept|
|
|
64
|
+
raw = @params[key]
|
|
65
|
+
kept[key] = raw if raw.is_a?(String) && raw.present?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The active filter and search values keyed by their logical (unprefixed)
|
|
70
|
+
# name — for rendering active-filter chips. Range bounds appear as
|
|
71
|
+
# `<field>_geq` / `<field>_leq`; the search box as `q`.
|
|
72
|
+
def active_filters
|
|
73
|
+
(filter_param_keys + ['q']).each_with_object({}) do |key, active|
|
|
74
|
+
val = param(key)
|
|
75
|
+
active[key] = val if val
|
|
76
|
+
end
|
|
48
77
|
end
|
|
49
78
|
|
|
79
|
+
def active? = active_filters.any?
|
|
80
|
+
|
|
50
81
|
# [field_name_string, direction_string] or nil.
|
|
51
82
|
def sort_state
|
|
52
83
|
field = current_sort_field
|
|
@@ -57,6 +88,14 @@ module CrudComponents
|
|
|
57
88
|
|
|
58
89
|
private
|
|
59
90
|
|
|
91
|
+
# Logical (unprefixed) filter param keys: each visible filter field's value
|
|
92
|
+
# plus its `_geq`/`_leq` bounds. Bounds are listed for every field even
|
|
93
|
+
# though only ranges use them — apply_filters reads all three uniformly, so
|
|
94
|
+
# the permit list mirrors exactly what can reach SQL.
|
|
95
|
+
def filter_param_keys
|
|
96
|
+
filter_fields.flat_map { |field| [field.name.to_s, "#{field.name}_geq", "#{field.name}_leq"] }
|
|
97
|
+
end
|
|
98
|
+
|
|
60
99
|
def prefix = param_prefix ? "#{param_prefix}_" : ''
|
|
61
100
|
|
|
62
101
|
def param(key)
|
|
@@ -75,12 +114,12 @@ module CrudComponents
|
|
|
75
114
|
|
|
76
115
|
def apply_filters(scope)
|
|
77
116
|
filter_fields.reduce(scope) do |current, field|
|
|
78
|
-
|
|
117
|
+
value = param(field.name.to_s)
|
|
79
118
|
geq = param("#{field.name}_geq")
|
|
80
119
|
leq = param("#{field.name}_leq")
|
|
81
|
-
next current unless
|
|
120
|
+
next current unless value || geq || leq
|
|
82
121
|
|
|
83
|
-
field.apply_filter(current,
|
|
122
|
+
field.apply_filter(current, value: value, geq: geq, leq: leq)
|
|
84
123
|
end
|
|
85
124
|
end
|
|
86
125
|
|
|
@@ -172,6 +172,13 @@ module CrudComponents
|
|
|
172
172
|
label_source.is_a?(Symbol) ? label_source : nil
|
|
173
173
|
end
|
|
174
174
|
|
|
175
|
+
# The label as a real database column, else nil (block label, or a Symbol
|
|
176
|
+
# naming a computed method). Only a column can be sorted or LIKE-matched.
|
|
177
|
+
def label_column_name
|
|
178
|
+
name = label_field_name
|
|
179
|
+
name if name && model.column_names.include?(name.to_s)
|
|
180
|
+
end
|
|
181
|
+
|
|
175
182
|
# The icon name (no library prefix) badging this model: the declared `icon`,
|
|
176
183
|
# else the name-based guess in `config.model_icons` (keyed by the singular
|
|
177
184
|
# underscored model name), else `config.model_fallback_icon` (nil = none).
|
|
@@ -189,9 +196,12 @@ module CrudComponents
|
|
|
189
196
|
@search_in_spec ||= (@search_decl.presence || default_search_spec)
|
|
190
197
|
end
|
|
191
198
|
|
|
199
|
+
# "Search what you see": with no search_in declared, ?q= covers the text
|
|
200
|
+
# shown on the index — own string/text columns, plus associations through
|
|
201
|
+
# their label. Columns you never display (and a model's hidden secrets) are
|
|
202
|
+
# never reached. Derived from the index fieldset; declared search_in wins.
|
|
192
203
|
def default_search_spec
|
|
193
|
-
|
|
194
|
-
.map { |col| col.name.to_sym }
|
|
204
|
+
fieldset_fields(fieldset(:index)).filter_map(&:search_spec_entry).uniq
|
|
195
205
|
end
|
|
196
206
|
|
|
197
207
|
def searchable?
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module CrudComponents
|
|
2
|
+
# The mechanism behind a typed dynamic-column filter (built internally by
|
|
3
|
+
# {Fields::Base} — you don't construct one). It pairs a value type with an apply
|
|
4
|
+
# block: the type drives casting and the rendered control, the block reaches SQL.
|
|
5
|
+
#
|
|
6
|
+
# A {DynamicColumn} gets one when its `filter:` block declares keyword params; the
|
|
7
|
+
# type comes from the column's `as:` (overridable with `filter_as:`). The block
|
|
8
|
+
# declares which of `eq:` / `geq:` / `leq:` / `contains:` it handles and is called
|
|
9
|
+
# with only those — each value already cast to the type (numeric → BigDecimal,
|
|
10
|
+
# date → Date, boolean → true/false), or nil when the param is blank or doesn't
|
|
11
|
+
# parse. Which control renders follows from the type and the declared keywords: a
|
|
12
|
+
# numeric/date block that asks for a bound (`geq:`/`leq:`) renders a range; one
|
|
13
|
+
# that asks only for `eq:` renders a single field.
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
16
|
+
class TypedFilter
|
|
17
|
+
TYPES = %i[text numeric date boolean select].freeze
|
|
18
|
+
|
|
19
|
+
# Every keyword a block may declare. The bare `?field=` value binds to
|
|
20
|
+
# `contains:` when the block asks for it, otherwise to `eq:`; the `_geq`/`_leq`
|
|
21
|
+
# params bind to `geq:`/`leq:`.
|
|
22
|
+
KEYWORDS = %i[eq contains geq leq choices].freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :type, :choices
|
|
25
|
+
|
|
26
|
+
def initialize(type, apply, choices: nil)
|
|
27
|
+
unless TYPES.include?(type)
|
|
28
|
+
raise ArgumentError, "unknown filter type #{type.inspect} — one of #{TYPES.map(&:inspect).join(', ')}"
|
|
29
|
+
end
|
|
30
|
+
raise ArgumentError, 'a typed filter needs an apply block (a callable)' unless apply.respond_to?(:call)
|
|
31
|
+
|
|
32
|
+
@type = type
|
|
33
|
+
@apply = apply
|
|
34
|
+
@choices = choices
|
|
35
|
+
@keywords = declared_keywords(apply)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The filter partial to render (`crud_components/filters/_<control>`).
|
|
39
|
+
def control
|
|
40
|
+
case type
|
|
41
|
+
when :numeric then range? ? :number_range : :number
|
|
42
|
+
when :date then range? ? :date_range : :date
|
|
43
|
+
else type
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Apply with the raw param values (Strings or nil): `value` is the bare
|
|
48
|
+
# `?field=`, `geq`/`leq` the bounds. Each is cast to the type and routed to the
|
|
49
|
+
# keyword the block declared; a value that doesn't cast becomes nil, so junk
|
|
50
|
+
# never reaches SQL.
|
|
51
|
+
def apply(scope, value: nil, geq: nil, leq: nil)
|
|
52
|
+
single = (@keywords.include?(:contains) && !@keywords.include?(:eq)) ? :contains : :eq
|
|
53
|
+
values = { single => cast(value), geq: cast(geq), leq: cast(leq), choices: @choices }
|
|
54
|
+
@apply.call(scope, **values.slice(*@keywords))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The `[label, value]` pairs a `:select` offers, or nil for any other type. A
|
|
58
|
+
# callable `choices` is resolved here (passed the query when it takes an arg).
|
|
59
|
+
def filter_choices(query = nil)
|
|
60
|
+
return nil unless type == :select
|
|
61
|
+
|
|
62
|
+
raw = @choices.respond_to?(:call) ? (@choices.arity.zero? ? @choices.call : @choices.call(query)) : @choices
|
|
63
|
+
Array(raw).map { |opt| opt.is_a?(Array) ? opt : [opt.to_s, opt] }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
TRUE_VALUES = %w[true t 1 yes on].freeze
|
|
69
|
+
FALSE_VALUES = %w[false f 0 no off].freeze
|
|
70
|
+
|
|
71
|
+
def range? = (@keywords & %i[geq leq]).any?
|
|
72
|
+
|
|
73
|
+
# The keywords the block declared. A block with `**` (keyrest) takes everything.
|
|
74
|
+
def declared_keywords(apply)
|
|
75
|
+
params = apply.respond_to?(:parameters) ? apply.parameters : []
|
|
76
|
+
return KEYWORDS if params.any? { |kind, _| kind == :keyrest }
|
|
77
|
+
|
|
78
|
+
params.filter_map { |kind, name| name if %i[key keyreq].include?(kind) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def cast(value)
|
|
82
|
+
return nil if value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
83
|
+
|
|
84
|
+
case type
|
|
85
|
+
when :numeric then cast_decimal(value)
|
|
86
|
+
when :date then cast_date(value)
|
|
87
|
+
when :boolean then cast_boolean(value)
|
|
88
|
+
else value
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def cast_decimal(value)
|
|
93
|
+
decimal = BigDecimal(value.to_s)
|
|
94
|
+
decimal.finite? ? decimal : nil
|
|
95
|
+
rescue ArgumentError, TypeError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def cast_date(value)
|
|
100
|
+
Date.parse(value.to_s)
|
|
101
|
+
rescue ArgumentError, TypeError, RangeError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def cast_boolean(value)
|
|
106
|
+
down = value.to_s.downcase
|
|
107
|
+
return true if TRUE_VALUES.include?(down)
|
|
108
|
+
return false if FALSE_VALUES.include?(down)
|
|
109
|
+
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/crud_components.rb
CHANGED
|
@@ -17,6 +17,13 @@ module CrudComponents
|
|
|
17
17
|
# nullable columns offer it as a "not set" choice). Improbable as a real
|
|
18
18
|
# value, so it never collides with a genuine enum key or boolean string.
|
|
19
19
|
NULL_FILTER_VALUE = '__null__'.freeze
|
|
20
|
+
|
|
21
|
+
# The two non-blank values of an attachment **presence** filter — its 3-state
|
|
22
|
+
# control (any / present / absent) submits these, and the query turns them into
|
|
23
|
+
# an EXISTS / NOT EXISTS (`where.associated` / `where.missing`) over the backing
|
|
24
|
+
# attachment association rather than a value match. See {Fields::AttachmentField}.
|
|
25
|
+
PRESENT_FILTER_VALUE = 'present'.freeze
|
|
26
|
+
ABSENT_FILTER_VALUE = 'absent'.freeze
|
|
20
27
|
end
|
|
21
28
|
|
|
22
29
|
require_relative 'crud_components/version'
|
|
@@ -25,6 +32,7 @@ require_relative 'crud_components/config'
|
|
|
25
32
|
require_relative 'crud_components/permission_context'
|
|
26
33
|
require_relative 'crud_components/like_spec'
|
|
27
34
|
require_relative 'crud_components/where_like'
|
|
35
|
+
require_relative 'crud_components/typed_filter'
|
|
28
36
|
require_relative 'crud_components/fields/base'
|
|
29
37
|
require_relative 'crud_components/fields/string_field'
|
|
30
38
|
require_relative 'crud_components/fields/text_field'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: crud_components
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anatoly Zelenin
|
|
@@ -75,6 +75,7 @@ extensions: []
|
|
|
75
75
|
extra_rdoc_files: []
|
|
76
76
|
files:
|
|
77
77
|
- ".gitignore"
|
|
78
|
+
- CHANGELOG.md
|
|
78
79
|
- Gemfile
|
|
79
80
|
- LICENSE
|
|
80
81
|
- README.md
|
|
@@ -110,8 +111,11 @@ files:
|
|
|
110
111
|
- app/views/crud_components/fields/_text.html.erb
|
|
111
112
|
- app/views/crud_components/fields/_url.html.erb
|
|
112
113
|
- app/views/crud_components/filters/_boolean.html.erb
|
|
114
|
+
- app/views/crud_components/filters/_date.html.erb
|
|
113
115
|
- app/views/crud_components/filters/_date_range.html.erb
|
|
116
|
+
- app/views/crud_components/filters/_number.html.erb
|
|
114
117
|
- app/views/crud_components/filters/_number_range.html.erb
|
|
118
|
+
- app/views/crud_components/filters/_presence.html.erb
|
|
115
119
|
- app/views/crud_components/filters/_select.html.erb
|
|
116
120
|
- app/views/crud_components/filters/_text.html.erb
|
|
117
121
|
- app/views/crud_components/form_fields/_belongs_to.html.erb
|
|
@@ -130,6 +134,7 @@ files:
|
|
|
130
134
|
- crud_components.gemspec
|
|
131
135
|
- docs/extending.md
|
|
132
136
|
- docs/fields.md
|
|
137
|
+
- docs/filtering.md
|
|
133
138
|
- docs/forms.md
|
|
134
139
|
- docs/performance.md
|
|
135
140
|
- docs/security.md
|
|
@@ -173,6 +178,7 @@ files:
|
|
|
173
178
|
- lib/crud_components/query.rb
|
|
174
179
|
- lib/crud_components/route_resolver.rb
|
|
175
180
|
- lib/crud_components/structure.rb
|
|
181
|
+
- lib/crud_components/typed_filter.rb
|
|
176
182
|
- lib/crud_components/version.rb
|
|
177
183
|
- lib/crud_components/where_like.rb
|
|
178
184
|
- lib/generators/crud_components/install/install_generator.rb
|
|
@@ -188,6 +194,7 @@ licenses:
|
|
|
188
194
|
metadata:
|
|
189
195
|
homepage_uri: https://github.com/itadventurer/crud_components
|
|
190
196
|
source_code_uri: https://github.com/itadventurer/crud_components
|
|
197
|
+
changelog_uri: https://github.com/itadventurer/crud_components/blob/main/CHANGELOG.md
|
|
191
198
|
rubygems_mfa_required: 'true'
|
|
192
199
|
rdoc_options: []
|
|
193
200
|
require_paths:
|