Class: CrudComponents::Presenters::Collection

Inherits:
Base
  • Object
show all
Includes:
ColumnSelection
Defined in:
lib/crud_components/presenters/collection.rb

Overview

The single ‘collection` local every layout partial receives.

query: :auto (default) → build a Query from the request params and apply it query: :static → no filter row, no sort links, params ignored query: <Query> → manual mode (records arrive already filtered)

picker: false (default) → no column picker; true → render the gear picked_columns: :auto (default) → read ?cols=; an Array → that exact

selection (no param read  the backend resolved it)

Defined Under Namespace

Classes: Group

Constant Summary

Constants inherited from Base

Base::GEM_VIEW_ROOT

Instance Attribute Summary collapse

Attributes inherited from Base

#view

Instance Method Summary collapse

Methods included from ColumnSelection

#column_visible?, #field_groups, #fields, #group_heading, #group_icon, #visible_columns

Methods inherited from Base

#ability, #config, #css, #permission_context, #render_cell, #render_filter_control

Constructor Details

#initialize(view:, records:, fieldset: nil, query: :auto, layout: :table, param_prefix: nil, actions: true, group_by: nil, extra_columns: nil, picker: false, picked_columns: :auto) ⇒ Collection

Returns a new instance of Collection.



17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/crud_components/presenters/collection.rb', line 17

def initialize(view:, records:, fieldset: nil, query: :auto, layout: :table,
               param_prefix: nil, actions: true, group_by: nil,
               extra_columns: nil, picker: false, picked_columns: :auto)
  super(view: view)
  unless records.respond_to?(:klass)
    raise ArgumentError,
          "crud_collection expects an ActiveRecord relation (e.g. Book.all, @books, or an " \
          "authorized scope like Book.accessible_by(current_ability)), got #{records.class}. " \
          'Pass a scope so your authorization and filtering apply before the gem renders.'
  end
  relation = records
  @model = relation.klass
  @structure = Structure.for(@model)
  @owner = relation.respond_to?(:proxy_association) ? relation.proxy_association.owner : nil
  @layout = layout
  @param_prefix = param_prefix
  @actions_enabled = actions
  # Two orthogonal column-picker knobs (see ColumnSelection): the gear is on
  # iff `picker`; the selection comes from the param (`:auto`) or verbatim
  # from the resolved Array — they never both read the param.
  @picker = picker
  @picked_columns = normalize_picked_columns(picked_columns)
  # User-defined columns whose data lives outside the model's table. Built
  # fresh per request (never on the immutable Structure), so they may carry
  # the per-page value cache.
  @dynamic_fields = Array(extra_columns).map { |c| c.to_field(@model) }

  case query
  when :static
    @static = true
    @fieldset = @structure.fieldset(fieldset || :index)
  when :auto, nil
    @fieldset = @structure.fieldset(fieldset || :index)
    @query = Query.new(@model, view.request.query_parameters, fieldset: @fieldset,
                       ability: ability, param_prefix: param_prefix, extra_fields: @dynamic_fields)
    relation = @query.apply(relation)
  when Query
    @query = query
    @fieldset = fieldset ? @structure.fieldset(fieldset) : query.fieldset
    @param_prefix = query.param_prefix
  else
    raise ArgumentError,
          "crud_collection query: expects :auto, :static or a CrudComponents::Query, got #{query.inspect}"
  end

  @relation = eager_load(relation)
  setup_grouping(group_by) if group_by
end

Instance Attribute Details

#fieldsetObject (readonly)

Returns the value of attribute fieldset.



15
16
17
# File 'lib/crud_components/presenters/collection.rb', line 15

def fieldset
  @fieldset
end

#layoutObject (readonly)

Returns the value of attribute layout.



15
16
17
# File 'lib/crud_components/presenters/collection.rb', line 15

def layout
  @layout
end

#modelObject (readonly)

Returns the value of attribute model.



15
16
17
# File 'lib/crud_components/presenters/collection.rb', line 15

def model
  @model
end

#ownerObject (readonly)

Returns the value of attribute owner.



15
16
17
# File 'lib/crud_components/presenters/collection.rb', line 15

def owner
  @owner
end

#param_prefixObject (readonly)

Returns the value of attribute param_prefix.



15
16
17
# File 'lib/crud_components/presenters/collection.rb', line 15

def param_prefix
  @param_prefix
end

#queryObject (readonly)

Returns the value of attribute query.



15
16
17
# File 'lib/crud_components/presenters/collection.rb', line 15

def query
  @query
end

#structureObject (readonly)

Returns the value of attribute structure.



15
16
17
# File 'lib/crud_components/presenters/collection.rb', line 15

def structure
  @structure
end

Instance Method Details

#actions_column?Boolean

── actions ──────────────────────────────────────────────────────────

Returns:

  • (Boolean)


354
355
356
# File 'lib/crud_components/presenters/collection.rb', line 354

def actions_column?
  @actions_enabled && (custom_actions_partial.present? || row_action_definitions.any?)
end

#available_fieldsObject

Every column this user is allowed to see — declared fieldset fields plus the dynamic columns — regardless of the current visibility selection. This is what a column picker offers as the universe to choose from. (‘fields`, `column_visible?` and the picker knobs come from ColumnSelection.)



83
84
85
86
# File 'lib/crud_components/presenters/collection.rb', line 83

def available_fields
  @available_fields ||=
    (structure.fieldset_fields(fieldset) + @dynamic_fields).select { |f| f.permitted?(permission_context) }
end

#cell(field, record) ⇒ Object

── cells ────────────────────────────────────────────────────────────



103
104
105
106
107
108
109
110
# File 'lib/crud_components/presenters/collection.rb', line 103

def cell(field, record)
  html = render_cell(field, record, surface: :collection, cell_context: cell_context)
  if label_link_field?(field) && (path = record_link(record))
    view.link_to(html, path, class: css.record_link, data: { turbo_action: 'advance' })
  else
    html
  end
end

#cell_contextObject

Click-to-filter context for value renderers — only when this collection actually has a live query.



114
115
116
117
118
# File 'lib/crud_components/presenters/collection.rb', line 114

def cell_context
  return nil if static? || query.nil?

  @cell_context ||= CellContext.new(view: view, query: query)
end

#collection_actionsObject



375
376
377
378
379
380
381
# File 'lib/crud_components/presenters/collection.rb', line 375

def collection_actions
  return nil unless @actions_enabled

  @collection_actions ||= Actions.new(view: view, subject: model, structure: structure,
                                      actions: structure.fieldset_actions(fieldset, on: :collection),
                                      owner: owner)
end

#column_header(field) ⇒ Object

The ‘<th>` title for `field`: a custom `header:` (a String — html_safe to carry markup — or a view-context block, e.g. a link) replaces the plain `human_name`; otherwise the `human_name` itself. `header:` substitutes only the name — the layout still wraps this in a sort link when the column is sortable, and appends any header actions.



141
142
143
144
145
146
147
148
# File 'lib/crud_components/presenters/collection.rb', line 141

def column_header(field)
  header = field.header
  case header
  when nil then field.human_name
  when Proc then view.instance_exec(&header)
  else header
  end
end

#column_header_actions(field) ⇒ Object

The header actions for ‘field` as an Actions presenter (collection-kind: they act on the column’s object × — for a :selection action — the ticked rows, not a single row), or nil. Each action’s path block closes over that object; the layout renders a :selection action as a select-form submitter and any other as a link/button.



155
156
157
158
159
160
161
# File 'lib/crud_components/presenters/collection.rb', line 155

def column_header_actions(field)
  return nil unless field.header_actions.any?

  (@column_header_actions ||= {})[field] ||=
    Actions.new(view: view, subject: model, structure: structure,
                actions: field.header_actions, owner: owner)
end

#column_param_nameObject

The checkbox param name the picker submits (respects param_prefix).



92
# File 'lib/crud_components/presenters/collection.rb', line 92

def column_param_name = "#{pn('cols')}[]"

#column_picker?Boolean

Whether to offer the column picker UI for this collection.

Returns:

  • (Boolean)


89
# File 'lib/crud_components/presenters/collection.rb', line 89

def column_picker? = @picker && available_fields.any?

#column_selection_actions?Boolean

A visible column may host an ‘on: :selection` action in its header (acting on the ticked rows × that column’s object). Like a toolbar selection action, it submits the shared select-form — so it needs the checkbox column + select-form and thus makes the collection selectable.

Returns:

  • (Boolean)


406
407
408
409
410
# File 'lib/crud_components/presenters/collection.rb', line 406

def column_selection_actions?
  fields.any? do |field|
    column_header_actions(field)&.items&.any? { |item| item.action.selection? }
  end
end

#columns_countObject



423
424
425
# File 'lib/crud_components/presenters/collection.rb', line 423

def columns_count
  fields.size + (selectable? ? 1 : 0) + (trailing_column? ? 1 : 0)
end

#current_pageObject



323
# File 'lib/crud_components/presenters/collection.rb', line 323

def current_page = @relation.current_page

#custom_actions_partialObject



365
366
367
# File 'lib/crud_components/presenters/collection.rb', line 365

def custom_actions_partial
  fieldset.custom_actions_partial
end

#custom_header?(field) ⇒ Boolean

Whether ‘field` brings its own header markup or header actions.

Returns:

  • (Boolean)


132
133
134
# File 'lib/crud_components/presenters/collection.rb', line 132

def custom_header?(field)
  field.custom_header?
end

#filter_fieldsObject



180
181
182
# File 'lib/crud_components/presenters/collection.rb', line 180

def filter_fields
  @filter_fields ||= static? ? [] : query.filter_fields
end

#filter_form_idObject



188
189
190
191
# File 'lib/crud_components/presenters/collection.rb', line 188

def filter_form_id
  suffix = param_prefix ? "_#{param_prefix}" : ''
  "crud_filter_#{model.model_name.plural}#{suffix}"
end

#filterable?Boolean

── filtering ────────────────────────────────────────────────────────

Returns:

  • (Boolean)


176
177
178
# File 'lib/crud_components/presenters/collection.rb', line 176

def filterable?
  !static? && query && filter_fields.any?
end

#filterable_field?(field) ⇒ Boolean

Returns:

  • (Boolean)


184
185
186
# File 'lib/crud_components/presenters/collection.rb', line 184

def filterable_field?(field)
  filter_fields.include?(field)
end

#filtered?Boolean

Returns:

  • (Boolean)


206
207
208
# File 'lib/crud_components/presenters/collection.rb', line 206

def filtered?
  !static? && query&.active?
end

#group_open?(group) ⇒ Boolean

Default: every group open below the collapse threshold, only the first above it. Once ‘?open=` is set it is authoritative (and may open several).

Returns:

  • (Boolean)


292
293
294
295
296
297
298
# File 'lib/crud_components/presenters/collection.rb', line 292

def group_open?(group)
  if open_keys.nil?
    records.size < config.group_collapse_threshold || group == groups.first
  else
    open_keys.include?(group.key)
  end
end

#group_toggle_url(group) ⇒ Object

Toggle this group in ‘?open=`, materializing the current open set so the first click on a default view keeps the others as they are.



302
303
304
305
306
307
# File 'lib/crud_components/presenters/collection.rb', line 302

def group_toggle_url(group)
  current = groups.select { |g| group_open?(g) }.map(&:key)
  toggled = current.include?(group.key) ? current - [group.key] : current + [group.key]
  params = view.request.query_parameters.merge(pn('open') => toggled.join(','))
  "#{view.request.path}?#{params.to_query}"
end

#grouped?Boolean

Returns:

  • (Boolean)


280
# File 'lib/crud_components/presenters/collection.rb', line 280

def grouped? = !@group_by.nil?

#groupsObject

The records split into groups, in group order (the relation is ordered by the group key first, so consecutive records form each group).



284
285
286
287
288
# File 'lib/crud_components/presenters/collection.rb', line 284

def groups
  @groups ||= records.group_by { |r| group_key_for(r) }.map do |key, recs|
    Group.new(key: key, label: group_label_for(recs.first), records: recs)
  end
end

Returns:

  • (Boolean)


120
121
122
# File 'lib/crud_components/presenters/collection.rb', line 120

def label_link_field?(field)
  field.name == structure.label_field_name
end

Returns:

  • (Boolean)


171
172
173
# File 'lib/crud_components/presenters/collection.rb', line 171

def label_link_present?(record)
  fields.any? { |f| label_link_field?(f) } && record_link(record).present?
end

#own_param_keysObject

Every param key this collection owns (for reset).



234
235
236
237
# File 'lib/crud_components/presenters/collection.rb', line 234

def own_param_keys
  keys = filter_fields.flat_map { |f| [pn(f.name.to_s), pn("#{f.name}_geq"), pn("#{f.name}_leq")] }
  keys + %w[q sort dir page per].map { |k| pn(k) }
end

#page_scopeObject

The underlying (possibly paginated) relation, for custom layouts that would rather drive their own pager — e.g. hand it to kaminari’s ‘paginate` helper instead of rendering the gem’s _pager.



330
# File 'lib/crud_components/presenters/collection.rb', line 330

def page_scope = @relation

#page_url(n) ⇒ Object

A URL for page n that keeps this collection’s filters/search/sort and every other collection’s params (only our own ‘page` changes) — so the pager composes with everything and respects `param_prefix:`.



335
336
337
338
# File 'lib/crud_components/presenters/collection.rb', line 335

def page_url(n)
  params = view.request.query_parameters.merge(pn('page') => n)
  "#{view.request.path}?#{params.to_query}"
end

#pager_pages(window: 2) ⇒ Object

Page numbers to show, with :gap markers for elided ranges: [1, :gap, 4, 5, 6, :gap, 10]. Always includes first/last and a window around the current page.



343
344
345
346
347
348
349
350
351
# File 'lib/crud_components/presenters/collection.rb', line 343

def pager_pages(window: 2)
  return [] if total_pages <= 1

  shown = ([1, total_pages] + ((current_page - window)..(current_page + window)).to_a)
          .select { |p| p >= 1 && p <= total_pages }.uniq.sort
  shown.each_with_index.flat_map do |p, i|
    (i.positive? && p - shown[i - 1] > 1) ? [:gap, p] : [p]
  end
end

#paginated?Boolean

── pagination ─────────────────────────────────────────────────────────We render a footer pager only when the relation handed to us is already paginated — i.e. the host called ‘.page` (kaminari / will_paginate, which decorate the relation). The gem never paginates on its own: no records arrive limited unless you asked for it. pagy keeps its state in a separate object, not on the relation, so it can’t be detected here —render ‘pagy_nav` yourself.

Returns:

  • (Boolean)


316
317
318
# File 'lib/crud_components/presenters/collection.rb', line 316

def paginated?
  @relation.respond_to?(:current_page) && @relation.respond_to?(:total_pages)
end

#picker_preserved_paramsObject

Hidden inputs for the picker’s GET form: keep every other param (filters, search, sort, other collections) but drop our own cols (the checkboxes resubmit it) and page (a column change resets paging).



97
98
99
100
# File 'lib/crud_components/presenters/collection.rb', line 97

def picker_preserved_params
  drop = [pn('cols'), pn('page')]
  view.request.query_parameters.reject { |key, _| drop.include?(key) }
end

#preserved_paramsObject

Hidden inputs for the filter form: keep this collection’s sort and every param that belongs to someone else (other prefixes, the page’s own params). Drop our own filter/search params — the controls themselves resubmit those.



227
228
229
230
231
# File 'lib/crud_components/presenters/collection.rb', line 227

def preserved_params
  own = filter_fields.flat_map { |f| [pn(f.name.to_s), pn("#{f.name}_geq"), pn("#{f.name}_leq")] }
  own += [pn('q'), pn('page'), pn('per')]
  view.request.query_parameters.reject { |key, _| own.include?(key) }
end


163
164
165
166
167
168
169
# File 'lib/crud_components/presenters/collection.rb', line 163

def record_link(record)
  @record_links ||= {}
  return @record_links[record.id] if @record_links.key?(record.id)

  found = RouteResolver.record_path(view, record, owner: owner)
  @record_links[record.id] = found&.first
end

#recordsObject



69
70
71
72
73
74
75
76
77
# File 'lib/crud_components/presenters/collection.rb', line 69

def records
  @records ||= begin
    rows = @relation.to_a
    # Prime each visible dynamic column's per-page cache once (no N+1); the
    # cell resolver then reads per row from what `preload:` returned.
    fields.each { |f| f.preload!(rows) if f.is_a?(Fields::DynamicField) }
    rows
  end
end

#reset_urlObject

Reset clears this collection’s filter/search/sort/page params and keeps everyone else’s (other prefixes, the page’s own params).



218
219
220
221
# File 'lib/crud_components/presenters/collection.rb', line 218

def reset_url
  kept = view.request.query_parameters.reject { |key, _| own_param_keys.include?(key) }
  kept.any? ? "#{view.request.path}?#{kept.to_query}" : view.request.path
end

#row_actions(record) ⇒ Object



369
370
371
372
373
# File 'lib/crud_components/presenters/collection.rb', line 369

def row_actions(record)
  Actions.new(view: view, subject: record, structure: structure,
              actions: row_action_definitions, owner: owner,
              suppress_show: label_link_present?(record))
end

#search_param_nameObject



198
199
200
# File 'lib/crud_components/presenters/collection.rb', line 198

def search_param_name
  query.param_name('q')
end

#search_valueObject



202
203
204
# File 'lib/crud_components/presenters/collection.rb', line 202

def search_value
  query&.value('q')
end

#searchable?Boolean

── header search (?q=) and reset ──────────────────────────────────────

Returns:

  • (Boolean)


194
195
196
# File 'lib/crud_components/presenters/collection.rb', line 194

def searchable?
  !static? && query && query.searchable?
end

#select_form_idObject



412
413
414
415
# File 'lib/crud_components/presenters/collection.rb', line 412

def select_form_id
  suffix = param_prefix ? "_#{param_prefix}" : ''
  "crud_select_#{model.model_name.plural}#{suffix}"
end

#select_param_nameObject

The checkbox param name (respects param_prefix) — value is each row’s identify_by, resolved back with CrudComponents.selected.



419
# File 'lib/crud_components/presenters/collection.rb', line 419

def select_param_name = "#{pn('selected')}[]"

#select_value(record) ⇒ Object



421
# File 'lib/crud_components/presenters/collection.rb', line 421

def select_value(record) = record.public_send(structure.identify_by).to_s

#selectable?Boolean

Returns:

  • (Boolean)


396
397
398
399
400
# File 'lib/crud_components/presenters/collection.rb', line 396

def selectable?
  return @selectable if defined?(@selectable)

  @selectable = @actions_enabled && (selection_actions.any? || column_selection_actions?)
end

#selection_actionsObject

── selection (bulk actions) ──────────────────────────────────────────Selection actions (‘action :x, on: :selection`) operate on the rows the user ticks. Rendered as submit buttons that post the checked `selected[]` slugs to the action path — no-JS works; the optional crud-select controller adds select-all / select-group / a live count.



388
389
390
391
392
393
394
# File 'lib/crud_components/presenters/collection.rb', line 388

def selection_actions
  return nil unless @actions_enabled

  @selection_actions ||= Actions.new(view: view, subject: model, structure: structure,
                                     actions: structure.fieldset_actions(fieldset, on: :selection),
                                     owner: owner)
end

#show_pager?Boolean

Whether to draw the footer at all — a single page needs no pager.

Returns:

  • (Boolean)


321
# File 'lib/crud_components/presenters/collection.rb', line 321

def show_pager? = paginated? && total_pages > 1

#show_toolbar?Boolean

Whether the toolbar (search + collection actions) has anything to show —lets a layout skip an empty header row.

Returns:

  • (Boolean)


212
213
214
# File 'lib/crud_components/presenters/collection.rb', line 212

def show_toolbar?
  searchable? || collection_actions&.any? || selection_actions&.any?
end

#sort_active?(field) ⇒ Boolean

Is this the column the result is currently sorted by?

Returns:

  • (Boolean)


252
253
254
# File 'lib/crud_components/presenters/collection.rb', line 252

def sort_active?(field)
  !sort_direction(field).nil?
end

#sort_direction(field) ⇒ Object

The active sort direction for a column — :asc / :desc, or nil when the result isn’t sorted by it. The presenter holds no icon names: a layout turns this tri-state into whatever glyph it likes (see _table), pairing it with ‘sort_numeric?` to choose a numeric vs alphabetic icon.



260
261
262
263
264
265
# File 'lib/crud_components/presenters/collection.rb', line 260

def sort_direction(field)
  current, dir = query&.sort_state
  return nil unless current == field.name.to_s

  dir.to_s == 'desc' ? :desc : :asc
end

#sort_numeric?(field) ⇒ Boolean

Whether this column sorts numerically (vs alphabetically) — numbers and dates do — so a layout can pick a sort-numeric vs sort-alpha glyph.

Returns:

  • (Boolean)


269
270
271
# File 'lib/crud_components/presenters/collection.rb', line 269

def sort_numeric?(field)
  field.is_a?(Fields::NumericField) || field.is_a?(Fields::DateField)
end

#sort_url(field) ⇒ Object



244
245
246
247
248
249
# File 'lib/crud_components/presenters/collection.rb', line 244

def sort_url(field)
  current, dir = query.sort_state
  next_dir = current == field.name.to_s && dir == 'asc' ? 'desc' : 'asc'
  params = view.request.query_parameters.merge(pn('sort') => field.name.to_s, pn('dir') => next_dir)
  "#{view.request.path}?#{params.to_query}"
end

#sortable_field?(field) ⇒ Boolean

── sorting ──────────────────────────────────────────────────────────

Returns:

  • (Boolean)


240
241
242
# File 'lib/crud_components/presenters/collection.rb', line 240

def sortable_field?(field)
  !static? && query && query.sortable_fields.include?(field)
end

#static?Boolean

Returns:

  • (Boolean)


66
# File 'lib/crud_components/presenters/collection.rb', line 66

def static? = !!@static

#surfaceObject



67
# File 'lib/crud_components/presenters/collection.rb', line 67

def surface = :collection

#total_countObject



325
# File 'lib/crud_components/presenters/collection.rb', line 325

def total_count  = @relation.total_count

#total_pagesObject



324
# File 'lib/crud_components/presenters/collection.rb', line 324

def total_pages  = @relation.total_pages

#trailing_column?Boolean

The trailing column exists when there are row actions or a column picker (its gear lives in that column’s header cell) — so the header, rows and width all agree even on a picker-only, action-less table.

Returns:

  • (Boolean)


361
362
363
# File 'lib/crud_components/presenters/collection.rb', line 361

def trailing_column?
  actions_column? || column_picker?
end