crud_components 0.1.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 (110) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/Gemfile +23 -0
  4. data/LICENSE +21 -0
  5. data/README.md +511 -0
  6. data/RELEASING.md +44 -0
  7. data/Rakefile +12 -0
  8. data/app/assets/stylesheets/crud_components.css +35 -0
  9. data/app/views/crud_components/_action_button.html.erb +11 -0
  10. data/app/views/crud_components/_actions.html.erb +12 -0
  11. data/app/views/crud_components/_column_header.html.erb +24 -0
  12. data/app/views/crud_components/_column_picker.html.erb +66 -0
  13. data/app/views/crud_components/_filter.html.erb +34 -0
  14. data/app/views/crud_components/_form.html.erb +30 -0
  15. data/app/views/crud_components/_pager.html.erb +41 -0
  16. data/app/views/crud_components/_record.html.erb +15 -0
  17. data/app/views/crud_components/_row.html.erb +26 -0
  18. data/app/views/crud_components/_selection_action.html.erb +14 -0
  19. data/app/views/crud_components/_sort_link.html.erb +17 -0
  20. data/app/views/crud_components/_toolbar.html.erb +50 -0
  21. data/app/views/crud_components/fields/_asciidoc.html.erb +8 -0
  22. data/app/views/crud_components/fields/_association.html.erb +13 -0
  23. data/app/views/crud_components/fields/_association_list.html.erb +24 -0
  24. data/app/views/crud_components/fields/_attachment.html.erb +16 -0
  25. data/app/views/crud_components/fields/_attachment_thumb.html.erb +17 -0
  26. data/app/views/crud_components/fields/_boolean.html.erb +13 -0
  27. data/app/views/crud_components/fields/_date.html.erb +6 -0
  28. data/app/views/crud_components/fields/_datetime.html.erb +6 -0
  29. data/app/views/crud_components/fields/_email.html.erb +7 -0
  30. data/app/views/crud_components/fields/_enum.html.erb +14 -0
  31. data/app/views/crud_components/fields/_json.html.erb +10 -0
  32. data/app/views/crud_components/fields/_markdown.html.erb +9 -0
  33. data/app/views/crud_components/fields/_number.html.erb +8 -0
  34. data/app/views/crud_components/fields/_string.html.erb +8 -0
  35. data/app/views/crud_components/fields/_text.html.erb +9 -0
  36. data/app/views/crud_components/fields/_url.html.erb +11 -0
  37. data/app/views/crud_components/filters/_boolean.html.erb +12 -0
  38. data/app/views/crud_components/filters/_date_range.html.erb +11 -0
  39. data/app/views/crud_components/filters/_number_range.html.erb +13 -0
  40. data/app/views/crud_components/filters/_select.html.erb +8 -0
  41. data/app/views/crud_components/filters/_text.html.erb +5 -0
  42. data/app/views/crud_components/form_fields/_belongs_to.html.erb +3 -0
  43. data/app/views/crud_components/form_fields/_boolean.html.erb +12 -0
  44. data/app/views/crud_components/form_fields/_date.html.erb +2 -0
  45. data/app/views/crud_components/form_fields/_datetime.html.erb +2 -0
  46. data/app/views/crud_components/form_fields/_enum.html.erb +8 -0
  47. data/app/views/crud_components/form_fields/_file.html.erb +47 -0
  48. data/app/views/crud_components/form_fields/_habtm.html.erb +5 -0
  49. data/app/views/crud_components/form_fields/_number.html.erb +2 -0
  50. data/app/views/crud_components/form_fields/_string.html.erb +3 -0
  51. data/app/views/crud_components/form_fields/_text.html.erb +2 -0
  52. data/app/views/crud_components/layouts/_table.html.erb +143 -0
  53. data/config/locales/crud_components.de.yml +39 -0
  54. data/config/locales/crud_components.en.yml +40 -0
  55. data/crud_components.gemspec +48 -0
  56. data/docs/extending.md +308 -0
  57. data/docs/fields.md +442 -0
  58. data/docs/forms.md +253 -0
  59. data/docs/performance.md +90 -0
  60. data/docs/security.md +139 -0
  61. data/docs/views.md +405 -0
  62. data/lib/crud_components/action.rb +85 -0
  63. data/lib/crud_components/builder.rb +246 -0
  64. data/lib/crud_components/config.rb +128 -0
  65. data/lib/crud_components/dynamic_column.rb +68 -0
  66. data/lib/crud_components/engine.rb +25 -0
  67. data/lib/crud_components/errors.rb +9 -0
  68. data/lib/crud_components/fields/attachment_field.rb +22 -0
  69. data/lib/crud_components/fields/base.rb +260 -0
  70. data/lib/crud_components/fields/belongs_to_field.rb +91 -0
  71. data/lib/crud_components/fields/boolean_field.rb +31 -0
  72. data/lib/crud_components/fields/computed_field.rb +34 -0
  73. data/lib/crud_components/fields/date_field.rb +51 -0
  74. data/lib/crud_components/fields/dynamic_field.rb +44 -0
  75. data/lib/crud_components/fields/enum_field.rb +40 -0
  76. data/lib/crud_components/fields/has_many_field.rb +50 -0
  77. data/lib/crud_components/fields/json_field.rb +10 -0
  78. data/lib/crud_components/fields/numeric_field.rb +31 -0
  79. data/lib/crud_components/fields/path_field.rb +327 -0
  80. data/lib/crud_components/fields/string_field.rb +41 -0
  81. data/lib/crud_components/fields/text_field.rb +9 -0
  82. data/lib/crud_components/fieldset.rb +38 -0
  83. data/lib/crud_components/helpers.rb +259 -0
  84. data/lib/crud_components/like_spec.rb +113 -0
  85. data/lib/crud_components/markup.rb +36 -0
  86. data/lib/crud_components/model.rb +33 -0
  87. data/lib/crud_components/permission_context.rb +62 -0
  88. data/lib/crud_components/presenters/actions.rb +51 -0
  89. data/lib/crud_components/presenters/base.rb +95 -0
  90. data/lib/crud_components/presenters/cell_context.rb +28 -0
  91. data/lib/crud_components/presenters/cells.rb +160 -0
  92. data/lib/crud_components/presenters/collection.rb +498 -0
  93. data/lib/crud_components/presenters/column_selection.rb +91 -0
  94. data/lib/crud_components/presenters/filter.rb +38 -0
  95. data/lib/crud_components/presenters/form.rb +57 -0
  96. data/lib/crud_components/presenters/record.rb +57 -0
  97. data/lib/crud_components/query.rb +110 -0
  98. data/lib/crud_components/route_resolver.rb +123 -0
  99. data/lib/crud_components/structure.rb +343 -0
  100. data/lib/crud_components/version.rb +3 -0
  101. data/lib/crud_components/where_like.rb +13 -0
  102. data/lib/crud_components.rb +160 -0
  103. data/lib/generators/crud_components/install/install_generator.rb +43 -0
  104. data/lib/generators/crud_components/install/templates/crud_columns_controller.js +76 -0
  105. data/lib/generators/crud_components/install/templates/crud_filter_controller.js +32 -0
  106. data/lib/generators/crud_components/install/templates/crud_multiselect_controller.js +70 -0
  107. data/lib/generators/crud_components/install/templates/crud_select_controller.js +35 -0
  108. data/lib/generators/crud_components/install/templates/initializer.rb +56 -0
  109. data/lib/generators/crud_components/views/views_generator.rb +14 -0
  110. metadata +209 -0
@@ -0,0 +1,260 @@
1
+ module CrudComponents
2
+ module Fields
3
+ # One subclass per field flavor (one row of the README's combination
4
+ # table). A field knows how it renders (which partial), how it filters
5
+ # (which control + how params reach SQL), and whether it sorts.
6
+ #
7
+ # Facets declared in an `attribute` block override exactly one of those:
8
+ # :render (block), :filter (like-spec / block / false), :sort
9
+ # (column symbol / block / false).
10
+ class Base
11
+ attr_reader :name, :model, :options, :facets
12
+
13
+ def initialize(name, model, options = {}, facets = {})
14
+ @name = name.to_sym
15
+ @model = model
16
+ @options = options
17
+ @facets = facets
18
+ end
19
+
20
+ def human_name
21
+ return options[:label] if options[:label].is_a?(String)
22
+
23
+ model.human_attribute_name(name)
24
+ end
25
+
26
+ # ── column header (issue #4) ───────────────────────────────────────────
27
+ # A column may own its `<th>`: a custom `header:` (a String rendered as-is —
28
+ # mark it html_safe for markup — or a view-context block, e.g. a link), and
29
+ # `header_actions:` — plain Action objects rendered in the header. A
30
+ # `:selection` action there acts on the ticked rows (it submits the shared
31
+ # select-form); any other action renders as a link/button. Available on every
32
+ # field flavor: a DynamicColumn passes them through its options, a declared
33
+ # `attribute` takes them as options too.
34
+ def header = options[:header]
35
+ def header_actions = Array(options[:header_actions])
36
+
37
+ # Whether this column brings its own header markup or header actions — the
38
+ # layout falls back to the plain human_name + sort link when it doesn't.
39
+ def custom_header? = !header.nil? || header_actions.any?
40
+
41
+ # Column-picker grouping: the heading this column sits under (a path
42
+ # column groups under the association(s) it reaches through), or nil for an
43
+ # own column. `picker_label` is the label shown within that group.
44
+ def group_label = nil
45
+ def picker_label = human_name
46
+
47
+ # The model the column-picker groups this column under (Pipedrive-style):
48
+ # its own model for a plain column, the *associated* model for an
49
+ # association or path column — so `publisher`, `publisher.name` and
50
+ # `publisher.founded_on` all sit under "Publisher".
51
+ def group_model = model
52
+
53
+ # The DB column backing this field, if any (nil for associations and
54
+ # computed fields).
55
+ def column
56
+ model.columns_hash[name.to_s]
57
+ end
58
+
59
+ # Whether the backing column permits NULL — gates the "not set" filter
60
+ # choice and the 3-state form control for nullable boolean/enum fields.
61
+ def nullable?
62
+ !!column&.null
63
+ end
64
+
65
+ # Whether this field's filter offers a "not set" (IS NULL) choice.
66
+ def filter_includes_null?
67
+ false
68
+ end
69
+
70
+ def value(record)
71
+ record.public_send(name)
72
+ end
73
+
74
+ # ── rendering ────────────────────────────────────────────────────────
75
+ def renderer(_record = nil)
76
+ options[:as] || default_renderer
77
+ end
78
+
79
+ def default_renderer
80
+ :string
81
+ end
82
+
83
+ def render_block
84
+ facets[:render]
85
+ end
86
+
87
+ def renderer_options
88
+ options.except(:as, :if, :form_as, :label, :header, :header_actions)
89
+ end
90
+
91
+ # ── permissions ──────────────────────────────────────────────────────
92
+ def permitted?(context, record = nil)
93
+ Permission.permitted?(options[:if], model, context, record)
94
+ end
95
+
96
+ # ── filtering ────────────────────────────────────────────────────────
97
+ def filterable?
98
+ return false if facets[:filter] == false
99
+ return false if CrudComponents::RESERVED_PARAMS.include?(name.to_s)
100
+ return true if filter_facet
101
+
102
+ derived_filterable?
103
+ end
104
+
105
+ def filter_facet
106
+ facets[:filter].is_a?(Proc) || facets[:filter].is_a?(Array) ||
107
+ facets[:filter].is_a?(Hash) || facets[:filter].is_a?(Symbol) ? facets[:filter] : nil
108
+ end
109
+
110
+ def derived_filterable?
111
+ false
112
+ end
113
+
114
+ # Which filter control partial to render: :text, :select, :boolean,
115
+ # :number_range or :date_range.
116
+ def filter_control
117
+ filter_facet ? :text : derived_filter_control
118
+ end
119
+
120
+ def derived_filter_control
121
+ :text
122
+ end
123
+
124
+ def filter_choices(_query = nil)
125
+ nil
126
+ end
127
+
128
+ def range_filter?
129
+ filter_control == :number_range || filter_control == :date_range
130
+ end
131
+
132
+ # `exact`, `geq`, `leq` are the raw param values (Strings or nil).
133
+ def apply_filter(scope, exact: nil, geq: nil, leq: nil)
134
+ if filter_facet
135
+ return scope unless exact
136
+
137
+ apply_filter_facet(scope, exact)
138
+ else
139
+ apply_derived_filter(scope, exact:, geq:, leq:)
140
+ end
141
+ end
142
+
143
+ def apply_filter_facet(scope, value)
144
+ facet = filter_facet
145
+ if facet.is_a?(Proc)
146
+ facet.call(scope.extending(WhereLike), value)
147
+ else
148
+ LikeSpec.apply(scope, facet, value)
149
+ end
150
+ end
151
+
152
+ def apply_derived_filter(scope, **)
153
+ scope
154
+ end
155
+
156
+ # ── sorting ──────────────────────────────────────────────────────────
157
+ def sortable?
158
+ return false if facets[:sort] == false
159
+ return false if CrudComponents::RESERVED_PARAMS.include?(name.to_s)
160
+ return true if sort_facet
161
+
162
+ derived_sortable?
163
+ end
164
+
165
+ def sort_facet
166
+ facets[:sort].is_a?(Proc) || facets[:sort].is_a?(Symbol) ? facets[:sort] : nil
167
+ end
168
+
169
+ def derived_sortable?
170
+ false
171
+ end
172
+
173
+ def apply_sort(scope, dir)
174
+ case (facet = sort_facet)
175
+ when Proc then facet.call(scope, dir)
176
+ when Symbol then scope.reorder(model.arel_table[facet].public_send(dir))
177
+ else scope.reorder(model.arel_table[name].public_send(dir))
178
+ end
179
+ end
180
+
181
+ # ── forms ──────────────────────────────────────────────────────────────
182
+ # Columns that exist but are never user-editable in a derived form.
183
+ NON_EDITABLE_COLUMNS = %w[id created_at updated_at].freeze
184
+
185
+ # Whether this field appears as an *input* in a derived form. `editable:`
186
+ # overrides; a symbol/Proc means "editable, subject to a can? check"
187
+ # (see editable_permitted?).
188
+ def editable?
189
+ case options[:editable]
190
+ when false then false
191
+ when nil then default_editable?
192
+ else true
193
+ end
194
+ end
195
+
196
+ def default_editable?
197
+ false
198
+ end
199
+
200
+ def editable_permitted?(context, record = nil)
201
+ condition = options[:editable]
202
+ return true unless condition.is_a?(Symbol) || condition.is_a?(Proc)
203
+
204
+ # recordless: false — a record-dependent `editable:` can't be granted by
205
+ # the class-level permit list (no record there); deny by default and let
206
+ # the per-record form check decide where a record is present.
207
+ Permission.permitted?(condition, model, context, record, recordless: false)
208
+ end
209
+
210
+ # The form-input flavor; nil = no form representation (json, computed).
211
+ def form_control
212
+ nil
213
+ end
214
+
215
+ # The form-input partial to render: crud_components/form_fields/_<name>.
216
+ # Defaults to the field's form_control type; override per field with
217
+ # `form_as:` (mirrors `as:` for the read-only/display renderer). The
218
+ # partial receives the simple_form builder `f`, the `field`, and `form`.
219
+ def form_partial
220
+ options[:form_as] || form_control
221
+ end
222
+
223
+ # What this field contributes to a strong-params permit list — a symbol
224
+ # or a nested hash; collected by Structure#permitted_params.
225
+ def permit_param
226
+ name
227
+ end
228
+
229
+ # ── loading ──────────────────────────────────────────────────────────
230
+ # Includes-specs (symbols/nested hashes for ActiveRecord#includes) to
231
+ # eager-load when this column is shown. Base contributes the per-attribute
232
+ # `preload:` — associations a render block / custom renderer reaches on the
233
+ # listed model. Association fields override to also nest the target's
234
+ # identity_preloads under the association name.
235
+ def eager_load
236
+ declared_preloads
237
+ end
238
+
239
+ # The `preload:` option as an array of includes-specs (a nested hash kept
240
+ # intact, unlike Array()).
241
+ def declared_preloads
242
+ case (p = options[:preload])
243
+ when nil then []
244
+ when Array then p
245
+ else [p]
246
+ end
247
+ end
248
+
249
+ private
250
+
251
+ def arel_column
252
+ model.arel_table[name]
253
+ end
254
+
255
+ def like_pattern(value)
256
+ "%#{ActiveRecord::Base.sanitize_sql_like(value)}%"
257
+ end
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,91 @@
1
+ module CrudComponents
2
+ module Fields
3
+ # belongs_to / has_one: nil-safe link via the target's label. The filter
4
+ # (belongs_to only) accepts both the target's identify_by value (what the
5
+ # select submits) and free text matched against the target's search_in —
6
+ # one param, two OR-combined parameterized subqueries.
7
+ class BelongsToField < Base
8
+ def default_renderer = :association
9
+
10
+ def reflection
11
+ @reflection ||= model.reflect_on_association(name)
12
+ end
13
+
14
+ def target
15
+ reflection.klass
16
+ end
17
+
18
+ # Picker grouping: a belongs_to/has_one column anchors its target's group
19
+ # (polymorphic has no single target, so it groups under its own model).
20
+ def group_model = reflection.polymorphic? ? model : target
21
+
22
+ def target_structure
23
+ Structure.for(target)
24
+ end
25
+
26
+ def derived_filterable?
27
+ reflection.belongs_to? && !reflection.polymorphic?
28
+ end
29
+
30
+ def derived_sortable? = false
31
+
32
+ # select (a dropdown of all targets) below `select_limit` rows, else free
33
+ # text. Counted per render, not memoized: the field instance lives on the
34
+ # process-cached Structure, so a memoized count would freeze at its boot-time
35
+ # value and render the wrong control once the table grows past the limit.
36
+ # One COUNT per filter-row render is negligible next to rendering the table.
37
+ def derived_filter_control
38
+ target.count <= CrudComponents.config.select_limit ? :select : :text
39
+ end
40
+
41
+ def filter_choices(_query = nil)
42
+ structure = target_structure
43
+ target.all.map { |record| [structure.label_for(record).to_s, record.public_send(structure.identify_by)] }
44
+ .sort_by(&:first)
45
+ end
46
+
47
+ def apply_derived_filter(scope, exact: nil, **)
48
+ return scope unless exact
49
+
50
+ identified = scope.where(name => target.where(target_structure.identify_by => exact))
51
+ searched = like_subquery(scope, exact)
52
+ searched ? identified.or(searched) : identified
53
+ end
54
+
55
+ # Load the association, nesting the target's identity_preloads (its label's
56
+ # own association deps) plus any per-column `preload:` so the target's
57
+ # label never N+1s. e.g. { order: %i[customer training] }.
58
+ # A polymorphic belongs_to has no single target class, so we can't nest its
59
+ # label's preloads — just preload the association itself (Rails groups it by
60
+ # type); the cell still renders each record's label and links it at runtime.
61
+ def eager_load
62
+ return [name] if reflection.polymorphic?
63
+
64
+ nested = (target_structure.identity_preloads + declared_preloads).uniq
65
+ [nested.empty? ? name : { name => nested }]
66
+ end
67
+
68
+ # ── forms ────────────────────────────────────────────────────────────
69
+ # Assigned via the foreign key; the select submits real ids (forms are
70
+ # POST bodies, not shareable URLs — unlike the filter, which uses
71
+ # identify_by).
72
+ def default_editable? = reflection.belongs_to? && !reflection.polymorphic?
73
+ def form_control = :belongs_to
74
+ def permit_param = reflection.foreign_key.to_sym
75
+
76
+ def form_choices
77
+ structure = target_structure
78
+ target.all.map { |record| [structure.label_for(record).to_s, record.id] }.sort_by(&:first)
79
+ end
80
+
81
+ private
82
+
83
+ def like_subquery(scope, value)
84
+ spec = target_structure.search_in_spec
85
+ return nil if spec.nil? || spec.empty?
86
+
87
+ scope.where(name => LikeSpec.apply(target.all, spec, value))
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,31 @@
1
+ module CrudComponents
2
+ module Fields
3
+ # boolean column: ✓/✗ cell, any/yes/no select; values cast & validated,
4
+ # invalid ones leave the scope unchanged.
5
+ class BooleanField < Base
6
+ def default_renderer = :boolean
7
+ def derived_filterable? = true
8
+ def derived_sortable? = true
9
+ def derived_filter_control = :boolean
10
+ def default_editable? = true
11
+ def form_control = :boolean
12
+
13
+ # Stricter than ActiveModel's cast (which makes any junk string true):
14
+ # only recognizable values filter, everything else is ignored.
15
+ TRUE_VALUES = %w[true t 1 yes on].freeze
16
+ FALSE_VALUES = %w[false f 0 no off].freeze
17
+
18
+ # A nullable column offers a "not set" (IS NULL) choice in the filter.
19
+ def filter_includes_null? = nullable?
20
+
21
+ def apply_derived_filter(scope, exact: nil, **)
22
+ case exact&.downcase
23
+ when *TRUE_VALUES then scope.where(name => true)
24
+ when *FALSE_VALUES then scope.where(name => false)
25
+ when CrudComponents::NULL_FILTER_VALUE then nullable? ? scope.where(name => nil) : scope
26
+ else scope
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,34 @@
1
+ module CrudComponents
2
+ module Fields
3
+ # A name Rails doesn't know: a public model method (rendered by its value
4
+ # type) or a declared render block. Query behavior only through facets —
5
+ # a Ruby-computed value has no SQL meaning until a facet gives it one.
6
+ class ComputedField < Base
7
+ def renderer(record = nil)
8
+ return options[:as] if options[:as]
9
+ return nil if render_block
10
+
11
+ record ? renderer_for_value(value(record)) : :string
12
+ end
13
+
14
+ def default_renderer = :string
15
+
16
+ def value(record)
17
+ render_block ? nil : record.public_send(name)
18
+ end
19
+
20
+ private
21
+
22
+ def renderer_for_value(value)
23
+ case value
24
+ when Numeric then :number
25
+ when Date then :date
26
+ when Time, DateTime then :datetime
27
+ when true, false then :boolean
28
+ when Hash, Array then :json
29
+ else :string
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,51 @@
1
+ module CrudComponents
2
+ module Fields
3
+ # date/datetime column: from–to range plus exact day; datetime ranges are
4
+ # whole-day-inclusive (a `leq` of 2026-01-31 includes that entire day).
5
+ class DateField < Base
6
+ def datetime?
7
+ @datetime ||= %i[datetime timestamp timestamptz].include?(model.columns_hash[name.to_s]&.type)
8
+ end
9
+
10
+ def default_renderer = datetime? ? :datetime : :date
11
+ def derived_filterable? = true
12
+ def derived_sortable? = true
13
+ def derived_filter_control = :date_range
14
+ def default_editable? = !NON_EDITABLE_COLUMNS.include?(name.to_s)
15
+ def form_control = datetime? ? :datetime : :date
16
+
17
+ def apply_derived_filter(scope, exact: nil, geq: nil, leq: nil)
18
+ if (d = cast(exact)) then scope = apply_day(scope, d) end
19
+ if (d = cast(geq)) then scope = scope.where(arel_column.gteq(lower_bound(d))) end
20
+ if (d = cast(leq)) then scope = scope.where(arel_column.lteq(upper_bound(d))) end
21
+ scope
22
+ end
23
+
24
+ private
25
+
26
+ def apply_day(scope, day)
27
+ if datetime?
28
+ scope.where(arel_column.gteq(lower_bound(day)).and(arel_column.lteq(upper_bound(day))))
29
+ else
30
+ scope.where(name => day)
31
+ end
32
+ end
33
+
34
+ def lower_bound(day)
35
+ datetime? ? day.beginning_of_day : day
36
+ end
37
+
38
+ def upper_bound(day)
39
+ datetime? ? day.end_of_day : day
40
+ end
41
+
42
+ def cast(value)
43
+ return nil if value.nil?
44
+
45
+ Date.parse(value)
46
+ rescue ArgumentError, TypeError, RangeError
47
+ nil
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,44 @@
1
+ module CrudComponents
2
+ module Fields
3
+ # The field flavor behind a {CrudComponents::DynamicColumn}: a column with no
4
+ # row in the model's table. It renders like a ComputedField (value typed, or
5
+ # `as:`), but the value comes from the column's resolver block rather than a
6
+ # method on the record, and a `preload:` lambda primes a per-page cache so a
7
+ # whole table costs one fetch, not one per row.
8
+ #
9
+ # Unlike the built-in fields — memoized on the immutable Structure and shared
10
+ # across every request — a DynamicField is built fresh per `crud_collection`
11
+ # call, so it may safely hold request state (the loaded cache). Filtering and
12
+ # sorting work only through the column's `filter:`/`sort:` facets; without
13
+ # them the column never reaches SQL, which keeps the query whitelist intact.
14
+ class DynamicField < ComputedField
15
+ # header:/header_actions: arrive via the column's options and are read by
16
+ # Fields::Base#header / #header_actions / #custom_header? — the layout picks
17
+ # them up through the Collection presenter, same as a declared attribute's.
18
+ def initialize(column, model)
19
+ super(column.name, model, column.options, column.facets)
20
+ @value_block = column.value_block
21
+ @preload_block = column.preload_block
22
+ @loaded = nil
23
+ end
24
+
25
+ # Run the batch loader once over the page's records; the resolver then
26
+ # reads per-record from whatever it returned. Called by the presenter just
27
+ # before rendering, only for the columns that end up visible.
28
+ def preload!(records)
29
+ @loaded = @preload_block&.call(records)
30
+ self
31
+ end
32
+
33
+ def value(record)
34
+ return super unless @value_block
35
+
36
+ @value_block.arity == 1 ? @value_block.call(record) : @value_block.call(record, @loaded)
37
+ end
38
+
39
+ # Never backed by a real DB column — keep column/nullable introspection
40
+ # nil so nothing tries to read model.columns_hash[name].
41
+ def column = nil
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,40 @@
1
+ module CrudComponents
2
+ module Fields
3
+ # enum: badge cell, select of enum keys, values validated against the
4
+ # enum definition — invalid ones leave the scope unchanged.
5
+ class EnumField < Base
6
+ def default_renderer = :enum
7
+ def derived_filterable? = true
8
+ def derived_sortable? = true
9
+ def derived_filter_control = :select
10
+ def default_editable? = true
11
+ def form_control = :enum
12
+
13
+ def form_choices
14
+ enum_keys.map { |key| [human_value(key), key] }
15
+ end
16
+
17
+ def enum_keys
18
+ model.defined_enums[name.to_s].keys
19
+ end
20
+
21
+ def filter_choices(_query = nil)
22
+ enum_keys.map { |key| [human_value(key), key] }
23
+ end
24
+
25
+ def human_value(key)
26
+ model.human_attribute_name("#{name}.#{key}", default: key.to_s.humanize)
27
+ end
28
+
29
+ # A nullable column offers a "not set" (IS NULL) choice in the filter.
30
+ def filter_includes_null? = nullable?
31
+
32
+ def apply_derived_filter(scope, exact: nil, **)
33
+ return scope.where(name => nil) if exact == CrudComponents::NULL_FILTER_VALUE && nullable?
34
+ return scope unless exact && enum_keys.include?(exact)
35
+
36
+ scope.where(name => exact)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,50 @@
1
+ module CrudComponents
2
+ module Fields
3
+ # has_many / habtm: truncated list of links ("a, b +3 more"). No derived
4
+ # filter or sort; opt in with `filter like: :assoc` (delegation).
5
+ class HasManyField < Base
6
+ def default_renderer = :association_list
7
+
8
+ def reflection
9
+ @reflection ||= model.reflect_on_association(name)
10
+ end
11
+
12
+ def target
13
+ reflection.klass
14
+ end
15
+
16
+ # Picker grouping: a has_many/habtm column anchors its target's group, so
17
+ # `authors`, `authors.name` and `authors.email` all sit under "Author".
18
+ def group_model = target
19
+
20
+ # Load the association, nesting each target's identity_preloads (+ this
21
+ # column's `preload:`) so rendering the list's labels never N+1s.
22
+ def eager_load
23
+ nested = (target_structure.identity_preloads + declared_preloads).uniq
24
+ [nested.empty? ? name : { name => nested }]
25
+ end
26
+
27
+ # The index a "+n more" / list link points at: the nested route under
28
+ # the owner if it resolves, else the target's index filtered by the
29
+ # owner. Resolved in the view (RouteResolver); here we just expose the
30
+ # association so the renderer can build it.
31
+ def collection_link_target = target
32
+
33
+ # ── forms ────────────────────────────────────────────────────────────
34
+ # Editable only for habtm (and simple has_many) via the *_ids setter —
35
+ # reassigning ids is safe; nested attributes are out of scope.
36
+ def habtm? = reflection.macro == :has_and_belongs_to_many
37
+ def default_editable? = habtm?
38
+ def form_control = :habtm
39
+ def ids_method = "#{name.to_s.singularize}_ids".to_sym
40
+ def permit_param = { ids_method => [] }
41
+
42
+ def form_choices
43
+ structure = target_structure
44
+ target.all.map { |record| [structure.label_for(record).to_s, record.id] }.sort_by(&:first)
45
+ end
46
+
47
+ def target_structure = Structure.for(target)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,10 @@
1
+ module CrudComponents
2
+ module Fields
3
+ # json/jsonb column: pretty-printed <pre>; no derived filter or sort.
4
+ class JsonField < Base
5
+ def default_renderer = :json
6
+ # JSON columns are not form-editable in v1: round-tripping raw JSON
7
+ # through a textarea needs parse-on-assign. Renders read-only.
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,31 @@
1
+ module CrudComponents
2
+ module Fields
3
+ # numeric column: min–max range plus exact match; unparsable values ignored.
4
+ class NumericField < Base
5
+ def default_renderer = :number
6
+ def derived_filterable? = true
7
+ def derived_sortable? = true
8
+ def derived_filter_control = :number_range
9
+ def default_editable? = !NON_EDITABLE_COLUMNS.include?(name.to_s)
10
+ def form_control = :number
11
+
12
+ def apply_derived_filter(scope, exact: nil, geq: nil, leq: nil)
13
+ if (v = cast(exact)) then scope = scope.where(name => v) end
14
+ if (v = cast(geq)) then scope = scope.where(arel_column.gteq(v)) end
15
+ if (v = cast(leq)) then scope = scope.where(arel_column.lteq(v)) end
16
+ scope
17
+ end
18
+
19
+ private
20
+
21
+ def cast(value)
22
+ return nil if value.nil?
23
+
24
+ decimal = BigDecimal(value)
25
+ decimal.finite? ? decimal : nil # reject NaN / Infinity — they aren't filters
26
+ rescue ArgumentError, TypeError
27
+ nil
28
+ end
29
+ end
30
+ end
31
+ end