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, extra_actions: nil, except_actions: nil) ⇒ 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
66
67
68
# 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, extra_actions: nil,
               except_actions: nil)
  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
  @extra_actions = Array(extra_actions)
  @except_actions = Array(except_actions).map(&:to_sym)
  @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)


363
364
365
# File 'lib/crud_components/presenters/collection.rb', line 363

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



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

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

#cell(field, record) ⇒ Object

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



107
108
109
110
111
112
113
114
# File 'lib/crud_components/presenters/collection.rb', line 107

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.



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

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

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

#collection_actionsObject



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

def collection_actions
  return nil unless @actions_enabled

  @collection_actions ||= Actions.new(view: view, subject: model, structure: structure,
                                      actions: keep(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.



145
146
147
148
149
150
151
152
# File 'lib/crud_components/presenters/collection.rb', line 145

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.



159
160
161
162
163
164
165
# File 'lib/crud_components/presenters/collection.rb', line 159

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



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

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

#column_picker?Boolean

Whether to offer the column picker UI for this collection.

Returns:

  • (Boolean)


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

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)


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

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

#columns_countObject



436
437
438
# File 'lib/crud_components/presenters/collection.rb', line 436

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

#current_pageObject



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

def current_page = @relation.current_page

#custom_actions_partialObject



377
378
379
# File 'lib/crud_components/presenters/collection.rb', line 377

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)


136
137
138
# File 'lib/crud_components/presenters/collection.rb', line 136

def custom_header?(field)
  field.custom_header?
end

#filter_fieldsObject



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

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

#filter_form_idObject



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

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

#filterable?Boolean

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

Returns:

  • (Boolean)


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

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)


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

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

#filtered?Boolean

Returns:

  • (Boolean)


215
216
217
# File 'lib/crud_components/presenters/collection.rb', line 215

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)


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

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.



311
312
313
314
315
316
# File 'lib/crud_components/presenters/collection.rb', line 311

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)


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

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



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

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)


124
125
126
# File 'lib/crud_components/presenters/collection.rb', line 124

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

Returns:

  • (Boolean)


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

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



243
244
245
246
# File 'lib/crud_components/presenters/collection.rb', line 243

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.



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

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



344
345
346
347
# File 'lib/crud_components/presenters/collection.rb', line 344

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.



352
353
354
355
356
357
358
359
360
# File 'lib/crud_components/presenters/collection.rb', line 352

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)


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

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



101
102
103
104
# File 'lib/crud_components/presenters/collection.rb', line 101

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.



236
237
238
239
240
# File 'lib/crud_components/presenters/collection.rb', line 236

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


167
168
169
170
171
172
173
# File 'lib/crud_components/presenters/collection.rb', line 167

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



73
74
75
76
77
78
79
80
81
# File 'lib/crud_components/presenters/collection.rb', line 73

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



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

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



381
382
383
384
385
# File 'lib/crud_components/presenters/collection.rb', line 381

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



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

def search_param_name
  query.param_name('q')
end

#search_valueObject



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

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

#searchable?Boolean

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

Returns:

  • (Boolean)


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

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

#select_form_idObject



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

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.



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

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

#select_value(record) ⇒ Object



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

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

#selectable?Boolean

Returns:

  • (Boolean)


409
410
411
412
413
# File 'lib/crud_components/presenters/collection.rb', line 409

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.



400
401
402
403
404
405
406
407
# File 'lib/crud_components/presenters/collection.rb', line 400

def selection_actions
  return nil unless @actions_enabled

  @selection_actions ||= Actions.new(view: view, subject: model, structure: structure,
                                     actions: keep(structure.fieldset_actions(fieldset, on: :selection)) +
                                              @extra_actions.select { |action| action.on == :selection },
                                     owner: owner)
end

#show_pager?Boolean

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

Returns:

  • (Boolean)


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

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)


221
222
223
# File 'lib/crud_components/presenters/collection.rb', line 221

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)


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

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.



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

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)


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

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

#sort_url(field) ⇒ Object



253
254
255
256
257
258
# File 'lib/crud_components/presenters/collection.rb', line 253

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)


249
250
251
# File 'lib/crud_components/presenters/collection.rb', line 249

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

#static?Boolean

Returns:

  • (Boolean)


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

def static? = !!@static

#surfaceObject



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

def surface = :collection

#total_countObject



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

def total_count  = @relation.total_count

#total_pagesObject



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

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)


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

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