crud_components 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/README.md +13 -9
  4. data/RELEASING.md +14 -9
  5. data/app/views/crud_components/_filter.html.erb +20 -0
  6. data/app/views/crud_components/filters/_date.html.erb +5 -0
  7. data/app/views/crud_components/filters/_number.html.erb +6 -0
  8. data/app/views/crud_components/filters/_presence.html.erb +13 -0
  9. data/config/locales/crud_components.de.yml +7 -0
  10. data/config/locales/crud_components.en.yml +7 -0
  11. data/crud_components.gemspec +1 -0
  12. data/docs/fields.md +20 -48
  13. data/docs/filtering.md +166 -0
  14. data/docs/performance.md +1 -1
  15. data/docs/security.md +9 -7
  16. data/docs/views.md +8 -2
  17. data/lib/crud_components/dynamic_column.rb +11 -3
  18. data/lib/crud_components/fields/attachment_field.rb +23 -1
  19. data/lib/crud_components/fields/base.rb +71 -11
  20. data/lib/crud_components/fields/belongs_to_field.rb +38 -10
  21. data/lib/crud_components/fields/boolean_field.rb +2 -2
  22. data/lib/crud_components/fields/date_field.rb +2 -2
  23. data/lib/crud_components/fields/enum_field.rb +4 -4
  24. data/lib/crud_components/fields/has_many_field.rb +22 -2
  25. data/lib/crud_components/fields/numeric_field.rb +2 -2
  26. data/lib/crud_components/fields/path_field.rb +6 -6
  27. data/lib/crud_components/fields/string_field.rb +3 -3
  28. data/lib/crud_components/helpers.rb +19 -5
  29. data/lib/crud_components/like_spec.rb +32 -33
  30. data/lib/crud_components/presenters/collection.rb +17 -8
  31. data/lib/crud_components/presenters/filter.rb +27 -3
  32. data/lib/crud_components/query.rb +45 -6
  33. data/lib/crud_components/structure.rb +5 -2
  34. data/lib/crud_components/typed_filter.rb +113 -0
  35. data/lib/crud_components/version.rb +1 -1
  36. data/lib/crud_components.rb +8 -0
  37. metadata +8 -1
@@ -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.include?(field)
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.include?(field)
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 there are row actions *or* a column
359
- # picker (its gear lives in that column's header cell) so the header,
360
- # rows and width all agree even on a picker-only, action-less table.
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
- # Keep sort and foreign params; our own controls resubmit themselves.
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
- def active?
46
- keys = filter_fields.flat_map { |f| [f.name.to_s, "#{f.name}_geq", "#{f.name}_leq"] }
47
- (keys + ['q']).any? { |key| param(key) }
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
- exact = param(field.name.to_s)
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 exact || geq || leq
120
+ next current unless value || geq || leq
82
121
 
83
- field.apply_filter(current, exact: exact, geq: geq, leq: leq)
122
+ field.apply_filter(current, value: value, geq: geq, leq: leq)
84
123
  end
85
124
  end
86
125
 
@@ -189,9 +189,12 @@ module CrudComponents
189
189
  @search_in_spec ||= (@search_decl.presence || default_search_spec)
190
190
  end
191
191
 
192
+ # "Search what you see": with no search_in declared, ?q= covers the text
193
+ # shown on the index — own string/text columns, plus associations through
194
+ # their label. Columns you never display (and a model's hidden secrets) are
195
+ # never reached. Derived from the index fieldset; declared search_in wins.
192
196
  def default_search_spec
193
- model.columns.select { |col| %i[string text].include?(col.type) }
194
- .map { |col| col.name.to_sym }
197
+ fieldset_fields(fieldset(:index)).filter_map(&:search_spec_entry).uniq
195
198
  end
196
199
 
197
200
  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
@@ -1,3 +1,3 @@
1
1
  module CrudComponents
2
- VERSION = '0.1.0'.freeze
2
+ VERSION = '0.2.0'.freeze
3
3
  end
@@ -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.0
4
+ version: 0.2.0
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: