Module: CafeCar::Helpers
- Defined in:
- lib/cafe_car/helpers.rb
Instance Method Summary collapse
-
#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. -
#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.
- #ancestor_href? ⇒ Boolean
- #body_classes ⇒ Object
- #breadcrumbs(*items) ⇒ Object
-
#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"). -
#bulk_action_style(name) ⇒ Object
The button style (a Button flag like
:danger) for a bulk action, from the locale underbulk_actions.styles, or nil for the default style. -
#bulk_actions(klass = model) ⇒ Object
The bulk actions offered on this model's index table — the model policy's
permitted_bulk_actionslist is the source of truth. - #bulk_actions? ⇒ Boolean
-
#bulk_form(&block) ⇒ Object
Wraps the index table in the form that submits the selected row ids and the chosen bulk action.
- #cap ⇒ Object
- #capture ⇒ Object
- #cat(*args) ⇒ Object
-
#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 thexdate column atbygranularity. - #chart_for(objects, **options) ⇒ Object
-
#clear_filters_url ⇒ Object
"Clear all" target: the current params minus every active filter key (and the now-stale page), keeping the control params — q, sort, view, per.
-
#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.
-
#collection_actions(klass = model) ⇒ Object
The custom collection actions offered in this model's index toolbar — the model policy's
permitted_collection_actionslist is the source of truth, like #bulk_actions. - #comment(text) ⇒ Object
- #console? ⇒ Boolean
- #context(name = nil) ⇒ Object
- #context?(*names) ⇒ Boolean
-
#csv_url ⇒ Object
Current index URL as
.csv, carrying the on-screen filter + sort params so the export matches the view. - #current_href?(check_parameters: false) ⇒ Boolean
- #debug? ⇒ Boolean
-
#filter_association(klass, key) ⇒ Object
The model whose records an association filter's values are ids of, or nil for a non-association filter.
- #filter_form_for(objects, **options, &block) ⇒ Object
-
#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.
-
#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. -
#filter_value(klass, key, value) ⇒ Object
A filter value as chip text.
-
#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. - #get_partial(path) ⇒ Object
- #href_for(*parts, namespace: self.namespace, **params) ⇒ Object
- #icon(name = nil) ⇒ Object
- #link(object) ⇒ Object
- #link_to ⇒ Object
- #method_missing(name) ⇒ Object
-
#metric(label, &block) ⇒ Object
A dashboard metric tile: a label over the value captured from the block.
- #metric_for(model, name) ⇒ Object
-
#metric_label(model, name) ⇒ Object
A metric tile's label, from the locale: the pluralized model name for the whole-relation
:allmetric, elsemetrics.<name>(defaulting to the humanized scope name). -
#metrics(model) ⇒ Object
The policy-driven metric tiles for
model: one count tile per name in the model policy'spermitted_metrics(:all= the whole relation). - #namespace ⇒ Object
- #navigation ⇒ Object
- #partial?(path) ⇒ Boolean
- #present(*args, **options) ⇒ Object (also: #p)
- #table_for(objects, **options, &block) ⇒ Object
- #template_glob(glob) ⇒ Object
-
#theme_stylesheet_tag ⇒ Object
Stylesheet for the active bundled theme (
.CafeCar.theme), injected into every CafeCar page's - #title(object) ⇒ Object
-
#ui(*args) ⇒ Object
Returns a new
Context. - #ui_class(names, *args, **opts) ⇒ Object
- #view_url(view) ⇒ Object
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
75 |
# File 'lib/cafe_car/helpers.rb', line 75 def ancestor_href?(...) = URI(href_for(...)) < URI(url_for(request.url)) |
#body_classes ⇒ Object
34 |
# File 'lib/cafe_car/helpers.rb', line 34 def body_classes = [ *controller_path.split(?/), action_name, *@body_class ] |
#breadcrumbs(*items) ⇒ Object
210 211 212 |
# File 'lib/cafe_car/helpers.rb', line 210 def (*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
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 |
#cap ⇒ Object
58 59 60 |
# File 'lib/cafe_car/helpers.rb', line 58 def cap(*) capture { cat(*) } end |
#capture ⇒ Object
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, **) CafeCar[:ChartBuilder].new(self, objects:, column: params[:chart_x], bucket: params[:chart_by], metric: params[:chart_y], **) end |
#clear_filters_url ⇒ Object
"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)) 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
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
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_url ⇒ Object
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
74 |
# File 'lib/cafe_car/helpers.rb', line 74 def current_href?(*, check_parameters: false, **) = current_page?(href_for(*, **), check_parameters:) |
#debug? ⇒ 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
214 215 216 217 218 219 220 221 222 223 224 |
# File 'lib/cafe_car/helpers.rb', line 214 def filter_form_for(objects, **, &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: "", **, &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 |
#link(object) ⇒ Object
189 190 191 192 |
# File 'lib/cafe_car/helpers.rb', line 189 def link(object) @links ||= {} @links[object] ||= CafeCar[:LinkBuilder].new(self, object) end |
#link_to ⇒ Object
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 |
#namespace ⇒ Object
372 373 374 |
# File 'lib/cafe_car/helpers.rb', line 372 def namespace @namespace ||= controller_path.split("/").tap(&:pop).map(&:to_sym) end |
#navigation ⇒ Object
368 369 370 |
# File 'lib/cafe_car/helpers.rb', line 368 def @navigation ||= CafeCar::Navigation.new(self) end |
#partial?(path) ⇒ 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, **) @presenters ||= {} @presenters[[ args, ]] ||= CafeCar[:Presenter].present(self, *args, **) end |
#table_for(objects, **options, &block) ⇒ Object
226 227 228 |
# File 'lib/cafe_car/helpers.rb', line 226 def table_for(objects, **, &block) CafeCar[:TableBuilder].new(self, objects:, **, &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_tag ⇒ Object
Stylesheet for the active bundled theme (CafeCar.theme), injected
into every CafeCar page's
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 |