Module: CafeCar::Helpers

Defined in:
lib/cafe_car/helpers.rb

Instance Method Summary collapse

Dynamic Method Handling

This class handles dynamic methods through the method_missing method

#method_missing(name) ⇒ Object



376
377
378
379
# File 'lib/cafe_car/helpers.rb', line 376

def method_missing(name, ...)
  return ui.send(name, ...) if name =~ /^[A-Z]/
  super
end

Instance Method Details

#action_style(name) ⇒ Object

The button style for a custom (member or collection) action, from the locale under actions.styles — the same convention as #bulk_action_style.



290
291
292
# File 'lib/cafe_car/helpers.rb', line 290

def action_style(name)
  t("actions.styles.#{name}", default: nil)&.to_sym
end

#active_filters(klass = model) ⇒ Object

The active-filter chips above the index: one per leaf in the policy-gated filter set (permitted_filter_params), each carrying its human label, display value, and the URL that removes just that one filter — the current params minus its single key, so every OTHER filter plus q/sort/view survive. Reflecting the gated set (not raw request params) means a stray or non-permitted param never grows a chip.



98
99
100
101
102
103
# File 'lib/cafe_car/helpers.rb', line 98

def active_filters(klass = model)
  flatten_filters(permitted_filter_params).map do |key, value|
    { key:, label: filter_label(klass, key), value: filter_value(klass, key, value),
      remove_url: url_for(request.params.except(key, "page")) }
  end
end

#ancestor_href?Boolean

Returns:

  • (Boolean)


75
# File 'lib/cafe_car/helpers.rb', line 75

def ancestor_href?(...) = URI(href_for(...)) < URI(url_for(request.url))

#body_classesObject



34
# File 'lib/cafe_car/helpers.rb', line 34

def body_classes = [ *controller_path.split(?/), action_name, *@body_class ]


210
211
212
# File 'lib/cafe_car/helpers.rb', line 210

def breadcrumbs(*items)
  ui.Row safe_join(items.compact_blank, icon(:nav_arrow_right, :dim))
end

#bulk_action(name, style = bulk_action_style(name)) ⇒ Object

A single bulk-action submit button, wired to the table's BulkForm from the toolbar (form: "BulkForm"). The label comes from the locale (en.destroy), never a hardcoded string, and the button style from the locale too — a shipped default maps destroy to :danger (see bulk_actions.styles). The default _bulk_actions partial loops bulk_actions calling this; a host overriding the partial can call it directly for a bespoke set.



251
252
253
254
255
256
# File 'lib/cafe_car/helpers.rb', line 251

def bulk_action(name, style = bulk_action_style(name))
  label   = t(name, default: name.to_s.humanize)
  confirm = t(:bulk_confirm, scope: :helpers, action: label)
  Button(*style, tag: :button, type: :submit, form: "BulkForm",
    name: :bulk_action, value: name, data: { turbo_confirm: confirm }) { label }
end

#bulk_action_style(name) ⇒ Object

The button style (a Button flag like :danger) for a bulk action, from the locale under bulk_actions.styles, or nil for the default style.



260
261
262
# File 'lib/cafe_car/helpers.rb', line 260

def bulk_action_style(name)
  t("bulk_actions.styles.#{name}", default: nil)&.to_sym
end

#bulk_actions(klass = model) ⇒ Object

The bulk actions offered on this model's index table — the model policy's permitted_bulk_actions list is the source of truth. This only decides which buttons show; each selected row is still authorized one-by-one in the controller, so a shown action never bulk-bypasses a per-record denial.



239
240
241
# File 'lib/cafe_car/helpers.rb', line 239

def bulk_actions(klass = model)
  policy(klass.new).attributes.actions.bulk
end

#bulk_actions?Boolean

Returns:

  • (Boolean)


243
# File 'lib/cafe_car/helpers.rb', line 243

def bulk_actions? = bulk_actions.any?

#bulk_form(&block) ⇒ Object

Wraps the index table in the form that submits the selected row ids and the chosen bulk action. With no bulk actions available, the table renders as-is. The action bar itself lives up in the index toolbar (next to the search box); its buttons reach back into this form by id via their form="BulkForm".



336
337
338
339
340
341
342
343
# File 'lib/cafe_car/helpers.rb', line 336

def bulk_form(&block)
  content = capture(&block)
  return content unless bulk_actions?

  form_tag(url_for(action: :batch), method: :post, class: "BulkForm", id: "BulkForm") do
    content
  end
end

#capObject



58
59
60
# File 'lib/cafe_car/helpers.rb', line 58

def cap(*)
  capture { cat(*) }
end

#captureObject



62
63
64
65
66
# File 'lib/cafe_car/helpers.rb', line 62

def capture(*, &)
  super do
    yield(*).then { _1.try(:html_safe?) ? _1.to_s : _1 }
  end
end

#cat(*args) ⇒ Object



50
51
52
53
54
55
56
# File 'lib/cafe_car/helpers.rb', line 50

def cat(*args)
  args.flatten.each do |arg|
    arg = capture(&arg) if arg.respond_to?(:to_proc)
    arg = arg.to_s
    concat(arg) if arg.present?
  end
end

#chart(title, model:, x:, by: nil) ⇒ Object

A dashboard chart tile: a title over the dependency-free inline-SVG bar chart, built from model's records bucketed over the x date column at by granularity. x runs through ChartBuilder's date-column allowlist unchanged, so a column name can never reach SQL raw. The model's policy scope is the chart's row boundary.



305
306
307
# File 'lib/cafe_car/helpers.rb', line 305

def chart(title, model:, x:, by: nil)
  render "cafe_car/dashboard/chart", title:, objects: policy_scope(model), x:, by:
end

#chart_for(objects, **options) ⇒ Object



230
231
232
233
# File 'lib/cafe_car/helpers.rb', line 230

def chart_for(objects, **options)
  CafeCar[:ChartBuilder].new(self, objects:,
    column: params[:chart_x], bucket: params[:chart_by], metric: params[:chart_y], **options)
end

#clear_filters_urlObject

"Clear all" target: the current params minus every active filter key (and the now-stale page), keeping the control params — q, sort, view, per.



107
108
109
# File 'lib/cafe_car/helpers.rb', line 107

def clear_filters_url
  url_for(request.params.except(*flatten_filters(permitted_filter_params).keys, "page"))
end

#collection_action(name, style = action_style(name)) ⇒ Object

A collection-action button for the index toolbar: POSTs the named action to the generic collection route (Controller#collection_action), carrying the active filters + search so the action runs over exactly the viewed set. The label appends that set's count (localized helpers.collection_action — e.g. "Publish all 21"); style and confirm come from the locale like #bulk_action.



277
278
279
280
281
282
283
284
285
286
# File 'lib/cafe_car/helpers.rb', line 277

def collection_action(name, style = action_style(name))
  count   = filtered_scope.count
  action  = t(name, default: name.to_s.humanize)
  label   = t(:collection_action, scope: :helpers, action:, count:)
  confirm = t(:collection_confirm, scope: :helpers, action:, count:,
              models: model.model_name.human(count:).downcase)
  filters = search_term ? filter_params.merge(q: search_term) : filter_params
  url     = url_for(filters.merge(action: :collection_action, collection_action: name))
  button_to label, url, class: ui.Button(*style).class_name, data: { turbo_confirm: confirm }
end

#collection_actions(klass = model) ⇒ Object

The custom collection actions offered in this model's index toolbar — the model policy's permitted_collection_actions list is the source of truth, like #bulk_actions. This only decides which buttons show; the controller re-authorizes the POST (see Controller#collection_action).



268
269
270
# File 'lib/cafe_car/helpers.rb', line 268

def collection_actions(klass = model)
  policy(klass.new).attributes.actions.collection
end

#comment(text) ⇒ Object



348
349
350
# File 'lib/cafe_car/helpers.rb', line 348

def comment(text)
  "<!-- #{text} -->".html_safe
end

#console?Boolean

Returns:

  • (Boolean)


346
# File 'lib/cafe_car/helpers.rb', line 346

def console? = params.key?(:console)

#context(name = nil) ⇒ Object



168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/cafe_car/helpers.rb', line 168

def context(name = nil, &)
  @context ||= []

  if block_given?
    @context << name
    r = capture(&)
    @context.pop
    r
  else
    @context
  end
end

#context?(*names) ⇒ Boolean

Returns:

  • (Boolean)


181
182
183
184
185
186
187
# File 'lib/cafe_car/helpers.rb', line 181

def context?(*names)
  context.reverse_each do |ctx|
    return true if names.empty?
    names.pop if ctx == names.last
  end
  names.empty?
end

#csv_urlObject

Current index URL as .csv, carrying the on-screen filter + sort params so the export matches the view. Pagination is dropped (CSV exports the full set).



90
# File 'lib/cafe_car/helpers.rb', line 90

def csv_url = url_for(request.params.except("page", "per").merge(format: :csv))

#current_href?(check_parameters: false) ⇒ Boolean

Returns:

  • (Boolean)


74
# File 'lib/cafe_car/helpers.rb', line 74

def current_href?(*, check_parameters: false, **) = current_page?(href_for(*, **), check_parameters:)

#debug?Boolean

Returns:

  • (Boolean)


345
# File 'lib/cafe_car/helpers.rb', line 345

def debug?   = Rails.env.development? && request.local? && params.key?(:debug)

#filter_association(klass, key) ⇒ Object

The model whose records an association filter's values are ids of, or nil for a non-association filter. A foreign-key set resolves through the terminal belongs_to; a membership set (<assoc>.id[]) through the association named just before the .id. Reflects through the same Filter::FieldInfo the panel controls read, so titling never diverges from what the control's select shows.



160
161
162
163
164
165
166
# File 'lib/cafe_car/helpers.rb', line 160

def filter_association(klass, key)
  method          = filter_method(key)
  parent, _, leaf = method.rpartition(".")
  method          = parent if leaf == "id" && parent.present?
  field           = CafeCar["Filter::FieldInfo"].new(model: klass, method:)
  field.reflection&.klass if field.type.in?(%i[belongs_to has_many has_one])
end

#filter_form_for(objects, **options, &block) ⇒ Object

Raises:

  • (ArgumentError)


214
215
216
217
218
219
220
221
222
223
224
# File 'lib/cafe_car/helpers.rb', line 214

def filter_form_for(objects, **options, &block)
  raise ArgumentError, "First argument to filter_form_for cannot be nil" if objects.nil?

  form_for CafeCar[:FilterBuilder].new(objects, parsed_params),
           builder: CafeCar["Filter::FormBuilder"],
           method: :get,
           url: "",
           as: "",
           **options,
           &block
end

#filter_label(klass, key) ⇒ Object

The human label for a filter key, from the same Filter::FieldInfo the panel controls read (policy/locale-driven, with a humanized-attribute fallback) — never the raw column/operator key.



126
127
128
# File 'lib/cafe_car/helpers.rb', line 126

def filter_label(klass, key)
  CafeCar["Filter::FieldInfo"].new(model: klass, method: filter_method(key)).label
end

#filter_method(key) ⇒ Object

A filter key reduced to the attribute path its label reads: the trailing operator char (name~) and a trailing word-operator segment (price.min) drop off; a nested association path (client.status) keeps its dots.



133
134
135
136
137
# File 'lib/cafe_car/helpers.rb', line 133

def filter_method(key)
  segments = key.to_s.sub(/\W+$/, "").split(".")
  segments.pop if segments.size > 1 && CafeCar::QueryBuilder::OPS.key?(segments.last)
  segments.join(".")
end

#filter_value(klass, key, value) ⇒ Object

A filter value as chip text. An association filter — a belongs_to foreign-key set (owner_id[]) or a has_many/has_one membership set (line_items.id[]) — shows each referenced record's TITLE (the same title the filter's typeahead lists), so a chip reads "Owner: Jane Doe" not "Owner: 42". The whole set is resolved in ONE query, and an id that no longer resolves falls back to its raw value. Every other filter (enum/string/range/boolean) prints its value(s) as-is.



145
146
147
148
149
150
151
152
153
# File 'lib/cafe_car/helpers.rb', line 145

def filter_value(klass, key, value)
  ids   = Array.wrap(value)
  assoc = filter_association(klass, key)
  return ids.join(", ") unless assoc
  return ids.join(", ") unless policy(assoc).index?

  titles = policy_scope(assoc).where(id: ids).index_by { _1.id.to_s }
  ids.map { |id| titles[id.to_s]&.then { present(_1).title } || id }.join(", ")
end

#flatten_filters(params, prefix = "") ⇒ Object

Flattens the nested gated filter params back to the flat dot-keyed params request.params carries: every nested hash — an operator group ({min:}) or an association hop ({client: {status:}}) — recurses, joining segments with ".", so a leaf's key is exactly the one url_for drops to remove it. Non-hash leaves (a scalar, an id set, a parsed Range) end the walk.



116
117
118
119
120
121
# File 'lib/cafe_car/helpers.rb', line 116

def flatten_filters(params, prefix = "")
  params.each_with_object({}) do |(key, value), flat|
    path = "#{prefix}#{key}"
    value.is_a?(Hash) ? flat.merge!(flatten_filters(value, "#{path}.")) : flat[path] = value
  end
end

#get_partial(path) ⇒ Object



357
358
359
360
# File 'lib/cafe_car/helpers.rb', line 357

def get_partial(path)
  prefixes = path.include?(?/) ? [] : lookup_context.prefixes
  lookup_context.find(path, prefixes, true)
end

#href_for(*parts, namespace: self.namespace, **params) ⇒ Object



77
78
79
# File 'lib/cafe_car/helpers.rb', line 77

def href_for(*parts, namespace: self.namespace, **params)
  HrefBuilder.new(*parts, namespace:, template: self, **params).to_s
end

#icon(name = nil) ⇒ Object



199
200
201
202
203
204
205
206
207
208
# File 'lib/cafe_car/helpers.rb', line 199

def icon(name = nil, *, **, &)
  case name
  when Symbol
    class_ = name&.then { "iconoir-#{_1.to_s.dasherize}" }
  when String, Array
    label = name
  end

  ui.Icon(*label, *, tag: :i, class: class_, **, &)
end


189
190
191
192
# File 'lib/cafe_car/helpers.rb', line 189

def link(object)
  @links         ||= {}
  @links[object] ||= CafeCar[:LinkBuilder].new(self, object)
end

Raises:

  • (ArgumentError)


194
195
196
197
# File 'lib/cafe_car/helpers.rb', line 194

def link_to(...)
  raise ArgumentError, "Links cannot be nested" if context?(:a)
  context(:a) { super }
end

#metric(label, &block) ⇒ Object

A dashboard metric tile: a label over the value captured from the block. The block is host-authored (a real view), evaluated at render.



296
297
298
# File 'lib/cafe_car/helpers.rb', line 296

def metric(label, &block)
  render "cafe_car/dashboard/metric", label:, value: capture(&block)
end

#metric_for(model, name) ⇒ Object



318
319
320
321
322
# File 'lib/cafe_car/helpers.rb', line 318

def metric_for(model, name)
  scope = policy_scope(model)
  scope = scope.public_send(name) unless name.to_sym == :all
  metric(metric_label(model, name)) { scope.count }
end

#metric_label(model, name) ⇒ Object

A metric tile's label, from the locale: the pluralized model name for the whole-relation :all metric, else metrics.<name> (defaulting to the humanized scope name).



327
328
329
330
# File 'lib/cafe_car/helpers.rb', line 327

def metric_label(model, name)
  return model.model_name.human(count: 2) if name.to_sym == :all
  t("metrics.#{name}", default: name.to_s.humanize)
end

#metrics(model) ⇒ Object

The policy-driven metric tiles for model: one count tile per name in the model policy's permitted_metrics (:all = the whole relation). This is the default dashboard behavior — a host template overrides by calling metric directly instead.



313
314
315
316
# File 'lib/cafe_car/helpers.rb', line 313

def metrics(model)
  policy = policy(model.new)
  safe_join(policy.permitted_metrics.map { metric_for(model, _1) })
end

#namespaceObject



372
373
374
# File 'lib/cafe_car/helpers.rb', line 372

def namespace
  @namespace ||= controller_path.split("/").tap(&:pop).map(&:to_sym)
end


368
369
370
# File 'lib/cafe_car/helpers.rb', line 368

def navigation
  @navigation ||= CafeCar::Navigation.new(self)
end

#partial?(path) ⇒ Boolean

Returns:

  • (Boolean)


352
353
354
355
# File 'lib/cafe_car/helpers.rb', line 352

def partial?(path)
  prefixes = path.include?(?/) ? [] : lookup_context.prefixes
  lookup_context.any?(path, prefixes, true)
end

#present(*args, **options) ⇒ Object Also known as: p



68
69
70
71
# File 'lib/cafe_car/helpers.rb', line 68

def present(*args, **options)
  @presenters                  ||= {}
  @presenters[[ args, options ]] ||= CafeCar[:Presenter].present(self, *args, **options)
end

#table_for(objects, **options, &block) ⇒ Object



226
227
228
# File 'lib/cafe_car/helpers.rb', line 226

def table_for(objects, **options, &block)
  CafeCar[:TableBuilder].new(self, objects:, **options, &block)
end

#template_glob(glob) ⇒ Object



362
363
364
365
366
# File 'lib/cafe_car/helpers.rb', line 362

def template_glob(glob)
  lookup_context.view_paths
    .flat_map { _1.send(:template_glob, glob) }
    .map { ActionView::TemplatePath.parse(_1) }
end

#theme_stylesheet_tagObject

Stylesheet for the active bundled theme (CafeCar.theme), injected into every CafeCar page's . Each theme is self-contained — its light tokens plus a prefers-color-scheme: dark block — so dark mode needs no separate tag. Emitted after application.css so its :root tokens win.



40
41
42
# File 'lib/cafe_car/helpers.rb', line 40

def theme_stylesheet_tag
  stylesheet_link_tag "cafe_car/themes/#{CafeCar.theme}", "data-turbo-track": "reload"
end

#title(object) ⇒ Object



44
45
46
47
48
# File 'lib/cafe_car/helpers.rb', line 44

def title(object)
  present(object).title.presence.tap do |title|
    content_for(:title, title)
  end
end

#ui(*args) ⇒ Object

Returns a new Context. Used for instantiating components: ui.Button(:primary, "Submit")



4
5
6
7
8
9
10
11
12
13
14
15
16
17
# File 'lib/cafe_car/helpers.rb', line 4

def ui(*args, **, &)
  # For now, this must be defined in a helper instead of in the controller. Passing `view_context` or `helpers`
  # from the controller somehow breaks `capture`. `capture` will return the captured content, but the content
  # _also_ gets appended to the original output buffer.
  # This can be tested in a view by comparing the behavior of `= capture do` with
  # `= controller.view_context.capture do`; the latter outputs the content twice.
  if args.any?
    present(*args, **, &)
  elsif block_given?
    capture(&)
  else
    @ui ||= CafeCar::Context.new(self)
  end
end

#ui_class(names, *args, **opts) ⇒ Object



19
20
21
22
23
24
25
26
27
28
29
30
31
32
# File 'lib/cafe_car/helpers.rb', line 19

def ui_class(names, *args, **opts)
  names  = [ *names ].map(&:camelize)
  name   = names.join("_")
  args.flatten!
  args.compact_blank!
  opts.compact_blank!
  opts.merge!(*args.extract!(Hash))

  flags = args.extract!(Symbol)
  flags |= opts.extract_if! { _1.is_a? Symbol }.keys
  flags.map! { "#{name}-#{_1}" }

  [ *name, *flags, *args, *opts.keys ].join(" ")
end

#view_url(view) ⇒ Object



81
82
83
84
85
86
# File 'lib/cafe_car/helpers.rb', line 81

def view_url(view)
  view = view.to_s
  params = request.params.merge(view:)
  params.delete(:view) if params[:view] == default_view
  url_for(params)
end