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,498 @@
1
+ module CrudComponents
2
+ module Presenters
3
+ # The single `collection` local every layout partial receives.
4
+ #
5
+ # query: :auto (default) → build a Query from the request params and apply it
6
+ # query: :static → no filter row, no sort links, params ignored
7
+ # query: <Query> → manual mode (records arrive already filtered)
8
+ #
9
+ # picker: false (default) → no column picker; true → render the gear
10
+ # picked_columns: :auto (default) → read ?cols=; an Array → that exact
11
+ # selection (no param read — the backend resolved it)
12
+ class Collection < Base
13
+ include ColumnSelection
14
+
15
+ attr_reader :model, :structure, :fieldset, :query, :layout, :param_prefix, :owner
16
+
17
+ def initialize(view:, records:, fieldset: nil, query: :auto, layout: :table,
18
+ param_prefix: nil, actions: true, group_by: nil,
19
+ extra_columns: nil, picker: false, picked_columns: :auto)
20
+ super(view: view)
21
+ unless records.respond_to?(:klass)
22
+ raise ArgumentError,
23
+ "crud_collection expects an ActiveRecord relation (e.g. Book.all, @books, or an " \
24
+ "authorized scope like Book.accessible_by(current_ability)), got #{records.class}. " \
25
+ 'Pass a scope so your authorization and filtering apply before the gem renders.'
26
+ end
27
+ relation = records
28
+ @model = relation.klass
29
+ @structure = Structure.for(@model)
30
+ @owner = relation.respond_to?(:proxy_association) ? relation.proxy_association.owner : nil
31
+ @layout = layout
32
+ @param_prefix = param_prefix
33
+ @actions_enabled = actions
34
+ # Two orthogonal column-picker knobs (see ColumnSelection): the gear is on
35
+ # iff `picker`; the selection comes from the param (`:auto`) or verbatim
36
+ # from the resolved Array — they never both read the param.
37
+ @picker = picker
38
+ @picked_columns = normalize_picked_columns(picked_columns)
39
+ # User-defined columns whose data lives outside the model's table. Built
40
+ # fresh per request (never on the immutable Structure), so they may carry
41
+ # the per-page value cache.
42
+ @dynamic_fields = Array(extra_columns).map { |c| c.to_field(@model) }
43
+
44
+ case query
45
+ when :static
46
+ @static = true
47
+ @fieldset = @structure.fieldset(fieldset || :index)
48
+ when :auto, nil
49
+ @fieldset = @structure.fieldset(fieldset || :index)
50
+ @query = Query.new(@model, view.request.query_parameters, fieldset: @fieldset,
51
+ ability: ability, param_prefix: param_prefix, extra_fields: @dynamic_fields)
52
+ relation = @query.apply(relation)
53
+ when Query
54
+ @query = query
55
+ @fieldset = fieldset ? @structure.fieldset(fieldset) : query.fieldset
56
+ @param_prefix = query.param_prefix
57
+ else
58
+ raise ArgumentError,
59
+ "crud_collection query: expects :auto, :static or a CrudComponents::Query, got #{query.inspect}"
60
+ end
61
+
62
+ @relation = eager_load(relation)
63
+ setup_grouping(group_by) if group_by
64
+ end
65
+
66
+ def static? = !!@static
67
+ def surface = :collection
68
+
69
+ def records
70
+ @records ||= begin
71
+ rows = @relation.to_a
72
+ # Prime each visible dynamic column's per-page cache once (no N+1); the
73
+ # cell resolver then reads per row from what `preload:` returned.
74
+ fields.each { |f| f.preload!(rows) if f.is_a?(Fields::DynamicField) }
75
+ rows
76
+ end
77
+ end
78
+
79
+ # Every column this user is allowed to see — declared fieldset fields plus
80
+ # the dynamic columns — regardless of the current visibility selection.
81
+ # This is what a column picker offers as the universe to choose from.
82
+ # (`fields`, `column_visible?` and the picker knobs come from ColumnSelection.)
83
+ def available_fields
84
+ @available_fields ||=
85
+ (structure.fieldset_fields(fieldset) + @dynamic_fields).select { |f| f.permitted?(permission_context) }
86
+ end
87
+
88
+ # Whether to offer the column picker UI for this collection.
89
+ def column_picker? = @picker && available_fields.any?
90
+
91
+ # The checkbox param name the picker submits (respects param_prefix).
92
+ def column_param_name = "#{pn('cols')}[]"
93
+
94
+ # Hidden inputs for the picker's GET form: keep every other param (filters,
95
+ # search, sort, other collections) but drop our own cols (the checkboxes
96
+ # resubmit it) and page (a column change resets paging).
97
+ def picker_preserved_params
98
+ drop = [pn('cols'), pn('page')]
99
+ view.request.query_parameters.reject { |key, _| drop.include?(key) }
100
+ end
101
+
102
+ # ── cells ────────────────────────────────────────────────────────────
103
+ def cell(field, record)
104
+ html = render_cell(field, record, surface: :collection, cell_context: cell_context)
105
+ if label_link_field?(field) && (path = record_link(record))
106
+ view.link_to(html, path, class: css.record_link, data: { turbo_action: 'advance' })
107
+ else
108
+ html
109
+ end
110
+ end
111
+
112
+ # Click-to-filter context for value renderers — only when this
113
+ # collection actually has a live query.
114
+ def cell_context
115
+ return nil if static? || query.nil?
116
+
117
+ @cell_context ||= CellContext.new(view: view, query: query)
118
+ end
119
+
120
+ def label_link_field?(field)
121
+ field.name == structure.label_field_name
122
+ end
123
+
124
+ # ── column headers ─────────────────────────────────────────────────────
125
+ # A column may carry custom `<th>` content: a header (String or a
126
+ # view-context block) and/or its own actions — declared on a DynamicColumn
127
+ # or on a plain `attribute`. The layout renders these *instead of* the plain
128
+ # human_name + sort link; for every other field these return nil/[] and the
129
+ # layout keeps its default behavior.
130
+
131
+ # Whether `field` brings its own header markup or header actions.
132
+ def custom_header?(field)
133
+ field.custom_header?
134
+ end
135
+
136
+ # The `<th>` title for `field`: a custom `header:` (a String — html_safe to
137
+ # carry markup — or a view-context block, e.g. a link) replaces the plain
138
+ # `human_name`; otherwise the `human_name` itself. `header:` substitutes only
139
+ # the name — the layout still wraps this in a sort link when the column is
140
+ # sortable, and appends any header actions.
141
+ def column_header(field)
142
+ header = field.header
143
+ case header
144
+ when nil then field.human_name
145
+ when Proc then view.instance_exec(&header)
146
+ else header
147
+ end
148
+ end
149
+
150
+ # The header actions for `field` as an Actions presenter (collection-kind:
151
+ # they act on the column's object × — for a :selection action — the ticked
152
+ # rows, not a single row), or nil. Each action's path block closes over that
153
+ # object; the layout renders a :selection action as a select-form submitter
154
+ # and any other as a link/button.
155
+ def column_header_actions(field)
156
+ return nil unless field.header_actions.any?
157
+
158
+ (@column_header_actions ||= {})[field] ||=
159
+ Actions.new(view: view, subject: model, structure: structure,
160
+ actions: field.header_actions, owner: owner)
161
+ end
162
+
163
+ def record_link(record)
164
+ @record_links ||= {}
165
+ return @record_links[record.id] if @record_links.key?(record.id)
166
+
167
+ found = RouteResolver.record_path(view, record, owner: owner)
168
+ @record_links[record.id] = found&.first
169
+ end
170
+
171
+ def label_link_present?(record)
172
+ fields.any? { |f| label_link_field?(f) } && record_link(record).present?
173
+ end
174
+
175
+ # ── filtering ────────────────────────────────────────────────────────
176
+ def filterable?
177
+ !static? && query && filter_fields.any?
178
+ end
179
+
180
+ def filter_fields
181
+ @filter_fields ||= static? ? [] : query.filter_fields
182
+ end
183
+
184
+ def filterable_field?(field)
185
+ filter_fields.include?(field)
186
+ end
187
+
188
+ def filter_form_id
189
+ suffix = param_prefix ? "_#{param_prefix}" : ''
190
+ "crud_filter_#{model.model_name.plural}#{suffix}"
191
+ end
192
+
193
+ # ── header search (?q=) and reset ──────────────────────────────────────
194
+ def searchable?
195
+ !static? && query && query.searchable?
196
+ end
197
+
198
+ def search_param_name
199
+ query.param_name('q')
200
+ end
201
+
202
+ def search_value
203
+ query&.value('q')
204
+ end
205
+
206
+ def filtered?
207
+ !static? && query&.active?
208
+ end
209
+
210
+ # Whether the toolbar (search + collection actions) has anything to show —
211
+ # lets a layout skip an empty header row.
212
+ def show_toolbar?
213
+ searchable? || collection_actions&.any? || selection_actions&.any?
214
+ end
215
+
216
+ # Reset clears *this* collection's filter/search/sort/page params and
217
+ # keeps everyone else's (other prefixes, the page's own params).
218
+ def reset_url
219
+ kept = view.request.query_parameters.reject { |key, _| own_param_keys.include?(key) }
220
+ kept.any? ? "#{view.request.path}?#{kept.to_query}" : view.request.path
221
+ end
222
+
223
+ # Hidden inputs for the filter form: keep this collection's sort and
224
+ # every param that belongs to someone else (other prefixes, the page's
225
+ # own params). Drop our own filter/search params — the controls
226
+ # themselves resubmit those.
227
+ def preserved_params
228
+ own = filter_fields.flat_map { |f| [pn(f.name.to_s), pn("#{f.name}_geq"), pn("#{f.name}_leq")] }
229
+ own += [pn('q'), pn('page'), pn('per')]
230
+ view.request.query_parameters.reject { |key, _| own.include?(key) }
231
+ end
232
+
233
+ # Every param key this collection owns (for reset).
234
+ def own_param_keys
235
+ keys = filter_fields.flat_map { |f| [pn(f.name.to_s), pn("#{f.name}_geq"), pn("#{f.name}_leq")] }
236
+ keys + %w[q sort dir page per].map { |k| pn(k) }
237
+ end
238
+
239
+ # ── sorting ──────────────────────────────────────────────────────────
240
+ def sortable_field?(field)
241
+ !static? && query && query.sortable_fields.include?(field)
242
+ end
243
+
244
+ def sort_url(field)
245
+ current, dir = query.sort_state
246
+ next_dir = current == field.name.to_s && dir == 'asc' ? 'desc' : 'asc'
247
+ params = view.request.query_parameters.merge(pn('sort') => field.name.to_s, pn('dir') => next_dir)
248
+ "#{view.request.path}?#{params.to_query}"
249
+ end
250
+
251
+ # Is this the column the result is currently sorted by?
252
+ def sort_active?(field)
253
+ !sort_direction(field).nil?
254
+ end
255
+
256
+ # The active sort direction for a column — :asc / :desc, or nil when the
257
+ # result isn't sorted by it. The presenter holds no icon names: a layout
258
+ # turns this tri-state into whatever glyph it likes (see _table), pairing
259
+ # it with `sort_numeric?` to choose a numeric vs alphabetic icon.
260
+ def sort_direction(field)
261
+ current, dir = query&.sort_state
262
+ return nil unless current == field.name.to_s
263
+
264
+ dir.to_s == 'desc' ? :desc : :asc
265
+ end
266
+
267
+ # Whether this column sorts numerically (vs alphabetically) — numbers and
268
+ # dates do — so a layout can pick a sort-numeric vs sort-alpha glyph.
269
+ def sort_numeric?(field)
270
+ field.is_a?(Fields::NumericField) || field.is_a?(Fields::DateField)
271
+ end
272
+
273
+ # ── grouping ─────────────────────────────────────────────────────────
274
+ # A run of records sharing one group value. `key` is URL-safe (for ?open=),
275
+ # `label` is for display.
276
+ Group = Struct.new(:key, :label, :records, keyword_init: true) do
277
+ def count = records.size
278
+ end
279
+
280
+ def grouped? = !@group_by.nil?
281
+
282
+ # The records split into groups, in group order (the relation is ordered by
283
+ # the group key first, so consecutive records form each group).
284
+ def groups
285
+ @groups ||= records.group_by { |r| group_key_for(r) }.map do |key, recs|
286
+ Group.new(key: key, label: group_label_for(recs.first), records: recs)
287
+ end
288
+ end
289
+
290
+ # Default: every group open below the collapse threshold, only the first
291
+ # above it. Once `?open=` is set it is authoritative (and may open several).
292
+ def group_open?(group)
293
+ if open_keys.nil?
294
+ records.size < config.group_collapse_threshold || group == groups.first
295
+ else
296
+ open_keys.include?(group.key)
297
+ end
298
+ end
299
+
300
+ # Toggle this group in `?open=`, materializing the current open set so the
301
+ # first click on a default view keeps the others as they are.
302
+ def group_toggle_url(group)
303
+ current = groups.select { |g| group_open?(g) }.map(&:key)
304
+ toggled = current.include?(group.key) ? current - [group.key] : current + [group.key]
305
+ params = view.request.query_parameters.merge(pn('open') => toggled.join(','))
306
+ "#{view.request.path}?#{params.to_query}"
307
+ end
308
+
309
+ # ── pagination ─────────────────────────────────────────────────────────
310
+ # We render a footer pager only when the relation handed to us is already
311
+ # paginated — i.e. the host called `.page` (kaminari / will_paginate, which
312
+ # decorate the relation). The gem never paginates on its own: no records
313
+ # arrive limited unless you asked for it. pagy keeps its state in a
314
+ # separate object, not on the relation, so it can't be detected here —
315
+ # render `pagy_nav` yourself.
316
+ def paginated?
317
+ @relation.respond_to?(:current_page) && @relation.respond_to?(:total_pages)
318
+ end
319
+
320
+ # Whether to draw the footer at all — a single page needs no pager.
321
+ def show_pager? = paginated? && total_pages > 1
322
+
323
+ def current_page = @relation.current_page
324
+ def total_pages = @relation.total_pages
325
+ def total_count = @relation.total_count
326
+
327
+ # The underlying (possibly paginated) relation, for custom layouts that
328
+ # would rather drive their own pager — e.g. hand it to kaminari's
329
+ # `paginate` helper instead of rendering the gem's _pager.
330
+ def page_scope = @relation
331
+
332
+ # A URL for page n that keeps this collection's filters/search/sort and
333
+ # every other collection's params (only our own `page` changes) — so the
334
+ # pager composes with everything and respects `param_prefix:`.
335
+ def page_url(n)
336
+ params = view.request.query_parameters.merge(pn('page') => n)
337
+ "#{view.request.path}?#{params.to_query}"
338
+ end
339
+
340
+ # Page numbers to show, with :gap markers for elided ranges:
341
+ # [1, :gap, 4, 5, 6, :gap, 10]. Always includes first/last and a window
342
+ # around the current page.
343
+ def pager_pages(window: 2)
344
+ return [] if total_pages <= 1
345
+
346
+ shown = ([1, total_pages] + ((current_page - window)..(current_page + window)).to_a)
347
+ .select { |p| p >= 1 && p <= total_pages }.uniq.sort
348
+ shown.each_with_index.flat_map do |p, i|
349
+ (i.positive? && p - shown[i - 1] > 1) ? [:gap, p] : [p]
350
+ end
351
+ end
352
+
353
+ # ── actions ──────────────────────────────────────────────────────────
354
+ def actions_column?
355
+ @actions_enabled && (custom_actions_partial.present? || row_action_definitions.any?)
356
+ end
357
+
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.
361
+ def trailing_column?
362
+ actions_column? || column_picker?
363
+ end
364
+
365
+ def custom_actions_partial
366
+ fieldset.custom_actions_partial
367
+ end
368
+
369
+ def row_actions(record)
370
+ Actions.new(view: view, subject: record, structure: structure,
371
+ actions: row_action_definitions, owner: owner,
372
+ suppress_show: label_link_present?(record))
373
+ end
374
+
375
+ def collection_actions
376
+ return nil unless @actions_enabled
377
+
378
+ @collection_actions ||= Actions.new(view: view, subject: model, structure: structure,
379
+ actions: structure.fieldset_actions(fieldset, on: :collection),
380
+ owner: owner)
381
+ end
382
+
383
+ # ── selection (bulk actions) ──────────────────────────────────────────
384
+ # Selection actions (`action :x, on: :selection`) operate on the rows the
385
+ # user ticks. Rendered as submit buttons that post the checked
386
+ # `selected[]` slugs to the action path — no-JS works; the optional
387
+ # crud-select controller adds select-all / select-group / a live count.
388
+ def selection_actions
389
+ return nil unless @actions_enabled
390
+
391
+ @selection_actions ||= Actions.new(view: view, subject: model, structure: structure,
392
+ actions: structure.fieldset_actions(fieldset, on: :selection),
393
+ owner: owner)
394
+ end
395
+
396
+ def selectable?
397
+ return @selectable if defined?(@selectable)
398
+
399
+ @selectable = @actions_enabled && (selection_actions.any? || column_selection_actions?)
400
+ end
401
+
402
+ # A visible column may host an `on: :selection` action in its header (acting
403
+ # on the ticked rows × that column's object). Like a toolbar selection
404
+ # action, it submits the shared select-form — so it needs the checkbox
405
+ # column + select-form and thus makes the collection selectable.
406
+ def column_selection_actions?
407
+ fields.any? do |field|
408
+ column_header_actions(field)&.items&.any? { |item| item.action.selection? }
409
+ end
410
+ end
411
+
412
+ def select_form_id
413
+ suffix = param_prefix ? "_#{param_prefix}" : ''
414
+ "crud_select_#{model.model_name.plural}#{suffix}"
415
+ end
416
+
417
+ # The checkbox param name (respects param_prefix) — value is each row's
418
+ # identify_by, resolved back with CrudComponents.selected.
419
+ def select_param_name = "#{pn('selected')}[]"
420
+
421
+ def select_value(record) = record.public_send(structure.identify_by).to_s
422
+
423
+ def columns_count
424
+ fields.size + (selectable? ? 1 : 0) + (trailing_column? ? 1 : 0)
425
+ end
426
+
427
+ private
428
+
429
+ GROUP_NONE = 'none'.freeze
430
+
431
+ # Validate the group key and order the relation by it (groups contiguous),
432
+ # keeping the active sort as the secondary order.
433
+ def setup_grouping(group_by)
434
+ @group_by = group_by.to_sym
435
+ @group_field = structure.field(@group_by)
436
+ unless @group_field.is_a?(Fields::BelongsToField) || @group_field.column
437
+ raise ArgumentError,
438
+ "crud_collection group_by: :#{@group_by} must be a column, belongs_to or enum of " \
439
+ "#{model} — #{@group_field.class.name.split('::').last} can't be grouped (no SQL column to order by)."
440
+ end
441
+ return unless @relation.is_a?(ActiveRecord::Relation)
442
+
443
+ order_attr = @group_field.is_a?(Fields::BelongsToField) ? @group_field.reflection.foreign_key : @group_by
444
+ existing = @relation.order_values
445
+ @relation = @relation.reorder(model.arel_table[order_attr])
446
+ @relation = @relation.order(*existing) if existing.any?
447
+ end
448
+
449
+ def group_key_for(record)
450
+ value = record.public_send(@group_by)
451
+ return GROUP_NONE if value.nil?
452
+
453
+ if @group_field.is_a?(Fields::BelongsToField)
454
+ value.public_send(Structure.for(value.class).identify_by).to_s
455
+ else
456
+ value.to_s
457
+ end
458
+ end
459
+
460
+ def group_label_for(record)
461
+ value = record.public_send(@group_by)
462
+ return view.t('crud_components.group.none', default: '—') if value.nil?
463
+
464
+ if @group_field.is_a?(Fields::BelongsToField)
465
+ view.crud_label(value)
466
+ elsif @group_field.is_a?(Fields::EnumField)
467
+ @group_field.human_value(value)
468
+ else
469
+ value.to_s
470
+ end
471
+ end
472
+
473
+ # nil when ?open= is absent (use the default open rule); an array (possibly
474
+ # empty) when present (authoritative).
475
+ def open_keys
476
+ return @open_keys if defined?(@open_keys)
477
+
478
+ raw = view.request.query_parameters[pn('open')]
479
+ @open_keys = raw.nil? ? nil : raw.to_s.split(',')
480
+ end
481
+
482
+ def row_action_definitions
483
+ @row_action_definitions ||= structure.fieldset_actions(fieldset, on: :row)
484
+ end
485
+
486
+ def pn(key)
487
+ query ? query.param_name(key) : key
488
+ end
489
+
490
+ def eager_load(relation)
491
+ return relation unless relation.is_a?(ActiveRecord::Relation)
492
+
493
+ specs = fields.flat_map(&:eager_load)
494
+ specs.any? ? relation.includes(*specs) : relation
495
+ end
496
+ end
497
+ end
498
+ end
@@ -0,0 +1,91 @@
1
+ module CrudComponents
2
+ module Presenters
3
+ # Shared "which columns are shown" logic for any presenter that exposes
4
+ # `available_fields` (the permitted universe) and a `param_prefix`. Two knobs
5
+ # drive it (set as `@picker` and `@picked_columns` by the including presenter):
6
+ #
7
+ # @picker false → no picking (the fieldset governs); true → the view
8
+ # participates (a collection also renders the gear).
9
+ # @picked_columns :auto → read the `?cols=` submit; an Array → that exact
10
+ # selection, **without ever reading the param** (the backend
11
+ # already resolved it — from a persisted pref, or from the
12
+ # param via {CrudComponents.selected_columns}).
13
+ #
14
+ # The chosen selection is **always intersected with `available_fields`** — so a
15
+ # forged or stale selection can only hide or reorder columns, never reveal one
16
+ # the `if:` gate forbids. Mixed into both the collection and the record
17
+ # presenter, so a column picker drives a table and a detail view alike.
18
+ module ColumnSelection
19
+ # The columns actually rendered: the permitted set, narrowed and ordered
20
+ # by the user's selection when there is one.
21
+ def fields
22
+ @fields ||= select_visible(available_fields)
23
+ end
24
+
25
+ # Is this column part of the current view (ticked in the picker)?
26
+ def column_visible?(field) = fields.include?(field)
27
+
28
+ # The column-picker universe grouped by source model (Pipedrive-style):
29
+ # `[[model, fields], …]` with this collection's own model first, then each
30
+ # associated model in first-appearance order. So `publisher`,
31
+ # `publisher.name` and `publisher.founded_on` cluster under Publisher.
32
+ def field_groups
33
+ by_model = available_fields.group_by(&:group_model)
34
+ ordered = [model, *(by_model.keys - [model])]
35
+ ordered.filter_map { |m| [m, by_model[m]] if by_model[m] }
36
+ end
37
+
38
+ # A picker group's heading text and icon (no prefix), for a grouped model.
39
+ def group_heading(group_model) = group_model.model_name.human
40
+ def group_icon(group_model) = Structure.for(group_model).icon
41
+
42
+ # The ordered column names to show, or nil for "all permitted". The selection
43
+ # is independent of the gear: a resolved **Array** always applies (the backend
44
+ # decided it — the gear may live elsewhere, e.g. a standalone picker or a
45
+ # detail view), verbatim and without reading the param. `:auto` reads the
46
+ # `?cols=` submit **only when a gear is rendered here** (`picker: true`); with
47
+ # no gear here, `:auto` means "don't narrow" (a stray `?cols=` is ignored).
48
+ def visible_columns
49
+ return @visible_columns if defined?(@visible_columns)
50
+
51
+ @visible_columns =
52
+ if @picked_columns.is_a?(Array) then @picked_columns
53
+ elsif @picker then cols_param
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ # Normalize the `picked_columns:` knob: `:auto`/nil → `:auto`; an Array →
60
+ # its symbols. Anything else is a mistake worth catching at the call site.
61
+ def normalize_picked_columns(value)
62
+ case value
63
+ when :auto, nil then :auto
64
+ when Array then value.map(&:to_sym)
65
+ else
66
+ raise ArgumentError,
67
+ "picked_columns: expects :auto or an Array of column names, got #{value.inspect}"
68
+ end
69
+ end
70
+
71
+ def select_visible(list)
72
+ names = visible_columns
73
+ return list unless names
74
+
75
+ names.filter_map { |name| list.find { |field| field.name == name } }
76
+ end
77
+
78
+ # The picker submits `cols[]=a&cols[]=b` (no-JS) or, with the crud-columns
79
+ # controller, a single comma-joined `cols=a,b` (prettier URL). Both forms are
80
+ # parsed by {CrudComponents.selected_columns} (the same reader hosts use to
81
+ # persist a pick) — we just symbolize the result. nil when nothing was picked.
82
+ def cols_param
83
+ CrudComponents.selected_columns(column_request_params, param_prefix: param_prefix)&.map(&:to_sym)
84
+ end
85
+
86
+ def column_request_params
87
+ view.respond_to?(:request) && view.request ? view.request.query_parameters : {}
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,38 @@
1
+ module CrudComponents
2
+ module Presenters
3
+ # The single `filter` local of the standalone filter form partial.
4
+ # Renders the fieldset's filterable fields (including its `filters:`
5
+ # extension); never auto-submits — users compose several filters here.
6
+ class Filter < Base
7
+ attr_reader :model, :structure, :query
8
+
9
+ def initialize(view:, model:, fieldset: nil, query: nil, param_prefix: nil)
10
+ super(view: view)
11
+ @model = model.is_a?(Class) ? model : model.klass
12
+ @structure = Structure.for(@model)
13
+ @query = if query.is_a?(Query)
14
+ query
15
+ else
16
+ Query.new(@model, view.request.query_parameters,
17
+ fieldset: @structure.fieldset(fieldset || :index),
18
+ ability: ability, param_prefix: param_prefix)
19
+ end
20
+ end
21
+
22
+ def fields = query.filter_fields
23
+ def searchable? = query.searchable?
24
+ def form_path = view.request.path
25
+ def reset_path = view.request.path
26
+
27
+ def param_name(key) = query.param_name(key)
28
+ def value(key) = query.value(key)
29
+
30
+ # Keep sort and foreign params; our own controls resubmit themselves.
31
+ def preserved_params
32
+ own = fields.flat_map { |f| [param_name(f.name.to_s), param_name("#{f.name}_geq"), param_name("#{f.name}_leq")] }
33
+ own += [param_name('q'), param_name('page'), param_name('per')]
34
+ view.request.query_parameters.reject { |key, _| own.include?(key) }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,57 @@
1
+ module CrudComponents
2
+ module Presenters
3
+ # The single `form` local of the form partial. Derives a form from the
4
+ # same field metadata everything else uses; the host app's controller
5
+ # owns saving (with the matching CrudComponents.permitted_attributes list).
6
+ #
7
+ # Field selection falls back: the action's fieldset → :form → :default.
8
+ # A visible field that isn't editable (by type or permission) renders
9
+ # read-only rather than vanishing.
10
+ class Form < Base
11
+ attr_reader :record, :model, :structure, :action
12
+
13
+ def initialize(view:, record:, fieldset: nil, action: nil, url: nil, method: nil)
14
+ super(view: view)
15
+ @record = record
16
+ @model = record.class
17
+ @structure = Structure.for(@model)
18
+ @action = (action || (record.persisted? ? :edit : :new)).to_sym
19
+ @fieldset = fieldset ? @structure.fieldset(fieldset) : @structure.form_fieldset(@action)
20
+ @url = url
21
+ @method = method
22
+ end
23
+
24
+ # Visible fields that have a form representation (computed/json skipped).
25
+ def fields
26
+ structure.fieldset_fields(@fieldset)
27
+ .select { |f| f.form_control && f.permitted?(permission_context, record) }
28
+ end
29
+
30
+ def editable?(field)
31
+ field.editable? && field.editable_permitted?(permission_context, record)
32
+ end
33
+
34
+ def any_errors?
35
+ record.errors.any?
36
+ end
37
+
38
+ # Errors not attached to a visible field — base errors, or errors on a
39
+ # column the form doesn't show. Rendered in the summary so "fix N errors"
40
+ # is never a dead end with nothing to fix.
41
+ def summary_errors
42
+ shown = fields.map(&:name)
43
+ record.errors.reject { |error| shown.include?(error.attribute) }.map(&:full_message)
44
+ end
45
+
46
+ # form_with options; nil url/method let Rails infer from the record.
47
+ def form_options
48
+ { url: @url, method: @method }.compact
49
+ end
50
+
51
+ # Read-only display reuses the value renderer (record surface).
52
+ def display(field)
53
+ render_cell(field, record, surface: :record)
54
+ end
55
+ end
56
+ end
57
+ end