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: → 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, search_bar: 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
65
# File 'lib/crud_components/presenters/collection.rb', line 17

def initialize(view:, records:, fieldset: nil, query: :auto, layout: :table,
               param_prefix: nil, actions: true, search_bar: 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
  @search_bar_enabled = search_bar
  # 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)


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

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.)



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

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

#cell(field, record) ⇒ Object

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



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

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.



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

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

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

#collection_actionsObject



384
385
386
387
388
389
390
# File 'lib/crud_components/presenters/collection.rb', line 384

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.



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

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.



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

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).



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

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

#column_picker?Boolean

Whether to offer the column picker UI for this collection.

Returns:

  • (Boolean)


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

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)


415
416
417
418
419
# File 'lib/crud_components/presenters/collection.rb', line 415

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

#columns_countObject



432
433
434
# File 'lib/crud_components/presenters/collection.rb', line 432

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

#current_pageObject



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

def current_page = @relation.current_page

#custom_actions_partialObject



374
375
376
# File 'lib/crud_components/presenters/collection.rb', line 374

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)


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

def custom_header?(field)
  field.custom_header?
end

#filter_fieldsObject



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

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

#filter_form_idObject



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

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

#filterable?Boolean

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

Returns:

  • (Boolean)


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

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

#filterable_field?(field) ⇒ Boolean

Match by name, not object identity: a prebuilt Query carries its own DynamicField instances (from its extra_fields), while the collection built its own from extra_columns. Same column, different objects — include? would miss them and silently drop the inline filter/sort (issue #21). Declared fields are memoized on the Structure, so they share a name too.

Returns:

  • (Boolean)


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

def filterable_field?(field)
  filter_fields.any? { |f| f.name == field.name }
end

#filtered?Boolean

Returns:

  • (Boolean)


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

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)


298
299
300
301
302
303
304
# File 'lib/crud_components/presenters/collection.rb', line 298

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.



308
309
310
311
312
313
# File 'lib/crud_components/presenters/collection.rb', line 308

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)


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

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).



290
291
292
293
294
# File 'lib/crud_components/presenters/collection.rb', line 290

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)


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

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

Returns:

  • (Boolean)


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

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).



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

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.



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

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:.



341
342
343
344
# File 'lib/crud_components/presenters/collection.rb', line 341

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.



349
350
351
352
353
354
355
356
357
# File 'lib/crud_components/presenters/collection.rb', line 349

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)


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

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).



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

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.



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

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


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

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



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

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).



224
225
226
227
# File 'lib/crud_components/presenters/collection.rb', line 224

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



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

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



204
205
206
# File 'lib/crud_components/presenters/collection.rb', line 204

def search_param_name
  query.param_name('q')
end

#search_valueObject



208
209
210
# File 'lib/crud_components/presenters/collection.rb', line 208

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

#searchable?Boolean

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

Returns:

  • (Boolean)


200
201
202
# File 'lib/crud_components/presenters/collection.rb', line 200

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

#select_form_idObject



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

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.



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

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

#select_value(record) ⇒ Object



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

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

#selectable?Boolean

Returns:

  • (Boolean)


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

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.



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

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)


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

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)


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

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)


258
259
260
# File 'lib/crud_components/presenters/collection.rb', line 258

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.



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

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)


275
276
277
# File 'lib/crud_components/presenters/collection.rb', line 275

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

#sort_url(field) ⇒ Object



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

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)


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

def sortable_field?(field)
  !static? && query && query.sortable_fields.any? { |f| f.name == field.name }
end

#static?Boolean

Returns:

  • (Boolean)


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

def static? = !!@static

#surfaceObject



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

def surface = :collection

#total_countObject



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

def total_count  = @relation.total_count

#total_pagesObject



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

def total_pages  = @relation.total_pages

#trailing_column?Boolean

The trailing column exists when something needs to live in it: row actions (in the body cells), the column picker (its gear in the header cell), or the inline filter row (its submit/reset button sits in this column). Without it a filterable table would render filter inputs with no button to apply them. Keeping it here also keeps the header, rows and width all in agreement.

Returns:

  • (Boolean)


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

def trailing_column?
  actions_column? || column_picker? || filterable?
end