cafe_car 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/.claude-plugin/marketplace.json +23 -0
  3. data/README.md +417 -29
  4. data/Rakefile +2 -2
  5. data/app/assets/stylesheets/cafe_car/chart.css +41 -0
  6. data/app/assets/stylesheets/cafe_car/dashboard.css +43 -0
  7. data/app/assets/stylesheets/cafe_car/filters.css +50 -0
  8. data/app/assets/stylesheets/cafe_car/themes/cool2.css +2 -0
  9. data/app/assets/stylesheets/cafe_car/themes/warm.css +30 -0
  10. data/app/assets/stylesheets/cafe_car/tooltips.css +1 -1
  11. data/app/assets/stylesheets/cafe_car.css +14 -4
  12. data/app/assets/stylesheets/tom-select.css +95 -0
  13. data/app/assets/stylesheets/ui/Button.css +7 -5
  14. data/app/assets/stylesheets/ui/Input.css +92 -1
  15. data/app/assets/stylesheets/ui/Page.css +7 -0
  16. data/app/assets/stylesheets/ui/Table.css +25 -0
  17. data/app/controllers/cafe_car/dashboards_controller.rb +29 -0
  18. data/app/javascript/cafe_car.js +68 -0
  19. data/app/javascript/tom-select.complete.min.js +444 -0
  20. data/app/policies/cafe_car/application_policy.rb +1 -1
  21. data/app/presenters/cafe_car/active_storage/attachment_presenter.rb +4 -1
  22. data/app/presenters/cafe_car/presenter.rb +2 -2
  23. data/app/views/application/_actions.html.haml +9 -0
  24. data/app/views/application/_active_filters.html.haml +15 -0
  25. data/app/views/application/_belongs_to_filter.html.haml +6 -0
  26. data/app/views/application/_boolean_filter.html.haml +4 -0
  27. data/app/views/application/_bulk_actions.html.haml +9 -0
  28. data/app/views/application/_chart.html.haml +18 -0
  29. data/app/views/application/_controls.html.haml +8 -0
  30. data/app/views/application/_enum_filter.html.haml +5 -0
  31. data/app/views/application/_filter.html.haml +4 -0
  32. data/app/views/application/_filters.html.haml +13 -4
  33. data/app/views/application/_has_many_filter.html.haml +7 -0
  34. data/app/views/application/_head.html.haml +1 -0
  35. data/app/views/application/_index.html.haml +3 -1
  36. data/app/views/application/_index_actions.html.haml +12 -3
  37. data/app/views/application/_navigation.html.haml +5 -0
  38. data/app/views/application/_nested_field.html.haml +1 -1
  39. data/app/views/application/_range_filter.html.haml +8 -0
  40. data/app/views/application/_scope_filter.html.haml +5 -0
  41. data/app/views/application/_string_filter.html.haml +4 -0
  42. data/app/views/application/_table.html.haml +8 -6
  43. data/app/views/application/_text_filter.html.haml +1 -0
  44. data/app/views/cafe_car/application/index.html.haml +5 -2
  45. data/app/views/cafe_car/dashboard/_chart.html.haml +11 -0
  46. data/app/views/cafe_car/dashboard/_metric.html.haml +4 -0
  47. data/config/brakeman.ignore +0 -48
  48. data/config/importmap.rb +1 -0
  49. data/config/locales/en.yml +60 -0
  50. data/config/routes.rb +15 -2
  51. data/lib/cafe_car/attributes.rb +40 -12
  52. data/lib/cafe_car/chart_builder.rb +217 -0
  53. data/lib/cafe_car/controller/filtering.rb +108 -6
  54. data/lib/cafe_car/controller.rb +220 -11
  55. data/lib/cafe_car/engine.rb +2 -2
  56. data/lib/cafe_car/field_info.rb +14 -4
  57. data/lib/cafe_car/filter/field_info.rb +56 -7
  58. data/lib/cafe_car/filter/form_builder.rb +57 -7
  59. data/lib/cafe_car/filter_builder.rb +14 -1
  60. data/lib/cafe_car/form_builder.rb +61 -6
  61. data/lib/cafe_car/helpers.rb +196 -0
  62. data/lib/cafe_car/inputs/association_input.rb +11 -0
  63. data/lib/cafe_car/inputs/base_input.rb +56 -7
  64. data/lib/cafe_car/inputs/boolean_input.rb +9 -0
  65. data/lib/cafe_car/inputs/date_input.rb +8 -0
  66. data/lib/cafe_car/inputs/datetime_input.rb +8 -0
  67. data/lib/cafe_car/inputs/file_input.rb +11 -0
  68. data/lib/cafe_car/inputs/nested_input.rb +10 -0
  69. data/lib/cafe_car/inputs/number_input.rb +9 -0
  70. data/lib/cafe_car/inputs/password_input.rb +4 -2
  71. data/lib/cafe_car/inputs/rich_text_input.rb +9 -0
  72. data/lib/cafe_car/inputs/select_input.rb +10 -0
  73. data/lib/cafe_car/inputs/string_input.rb +3 -1
  74. data/lib/cafe_car/inputs/text_area_input.rb +9 -0
  75. data/lib/cafe_car/link_builder.rb +11 -0
  76. data/lib/cafe_car/model.rb +37 -8
  77. data/lib/cafe_car/navigation.rb +10 -0
  78. data/lib/cafe_car/policy.rb +85 -15
  79. data/lib/cafe_car/query_builder.rb +22 -0
  80. data/lib/cafe_car/routing.rb +26 -2
  81. data/lib/cafe_car/table/builder.rb +1 -1
  82. data/lib/cafe_car/table/foot_builder.rb +1 -0
  83. data/lib/cafe_car/table/head_builder.rb +6 -0
  84. data/lib/cafe_car/table/row_builder.rb +7 -0
  85. data/lib/cafe_car/version.rb +1 -1
  86. data/lib/cafe_car.rb +32 -2
  87. data/lib/generators/cafe_car/agents/USAGE +13 -0
  88. data/lib/generators/cafe_car/agents/agents_generator.rb +49 -0
  89. data/lib/generators/cafe_car/resource/resource_generator.rb +12 -1
  90. data/lib/tasks/demo.rake +9 -0
  91. data/llms.txt +25 -0
  92. data/skills/cafe_car/SKILL.md +135 -0
  93. data/skills/cafe_car/references/components.md +93 -0
  94. data/skills/cafe_car/references/controllers.md +106 -0
  95. data/skills/cafe_car/references/filtering.md +102 -0
  96. data/skills/cafe_car/references/forms.md +84 -0
  97. data/skills/cafe_car/references/locales.md +74 -0
  98. data/skills/cafe_car/references/navigation.md +67 -0
  99. data/skills/cafe_car/references/policies.md +112 -0
  100. data/skills/cafe_car/references/presenters.md +76 -0
  101. data/skills/cafe_car/references/turbo.md +53 -0
  102. data/skills/cafe_car/references/views.md +96 -0
  103. metadata +59 -16
  104. data/app/assets/stylesheets/cafe_car/themes/warm-dark.css +0 -29
  105. data/app/views/application/_search.html.haml +0 -6
  106. data/lib/cafe_car/auto_resolver.rb +0 -49
  107. data/lib/cafe_car/inputs/association_builder.rb +0 -6
  108. data/lib/cafe_car/inputs/belongs_to_builder.rb +0 -6
  109. data/lib/tasks/holdco_tasks.rake +0 -532
  110. data/lib/tasks/templates/tasks_header.md +0 -37
@@ -13,8 +13,21 @@ module CafeCar
13
13
  def persisted? = false
14
14
  def errors = Hash.new([])
15
15
 
16
+ # Rails' select/check_box read the current value off the object by the field
17
+ # name (`object.status`); a nested control's name is a dot-path
18
+ # (`client.status`), so split it to dig the nested param — the same segment
19
+ # walk as #value.
16
20
  def method_missing(name, *, &)
17
- @params.dig("", name)
21
+ @params.dig("", *name.to_s.split("."))
22
+ end
23
+
24
+ # Current value of a (possibly nested) filter param — `value(:price, :min)`
25
+ # reads `?price.min=`, and `value("client.owner_id")` digs the nested
26
+ # `?client.owner_id=` a nested control posts (a dotted key splits into its
27
+ # segments). Nil-safe against shapes a control can't express (a hand-typed
28
+ # `?price=10..20` parses to a Range, not a Hash).
29
+ def value(*keys)
30
+ keys.flat_map { _1.to_s.split(".") }.reduce(@params[""]) { |v, key| v[key] if v.is_a?(Hash) }
18
31
  end
19
32
  end
20
33
  end
@@ -13,17 +13,66 @@ module CafeCar
13
13
  def policy = @template.policy(@object)
14
14
  def show(...) = ui.Input { @template.present(@object).show(...) }
15
15
 
16
- def association(method, collection: nil, **options)
16
+ def association(method, collection: nil, multiple: false, **options)
17
17
  info = info(method)
18
18
 
19
19
  return show(info.input_key) if info.polymorphic? and object.persisted?
20
20
  return hidden(*info.polymorphic_methods) if info.polymorphic?
21
21
 
22
- collection ||= info.collection
23
- # options[:prompt] ||= info.prompt
22
+ collection ||= with_selected(info)
23
+ # A multi-select filters by a set (`author_id[]`), so no blank "any" option —
24
+ # an empty selection already means "any". A single select keeps its prompt.
25
+ options[:include_blank] ||= info.prompt unless multiple
26
+
27
+ html = searchable_select(info)
28
+ html[:multiple] = true if multiple
29
+
30
+ collection_select(info.input_key, collection, :id,
31
+ -> { @template.present(_1).title }, options, html)
32
+ end
33
+
34
+ # HTML options that flag an association <select> for Tom Select enhancement
35
+ # (see cafe_car.js). When the associated model exposes an `options` typeahead
36
+ # feed, its URL rides along so keystroke search can reach records past
37
+ # `max_collection_options`; without it the field degrades to a plain select.
38
+ def searchable_select(info)
39
+ data = { "searchable-select": "" }
40
+ url = options_url(info.reflection&.klass)
41
+ data["searchable-select-url"] = url if url
42
+ { data: }
43
+ end
44
+
45
+ # URL of the associated model's typeahead feed in the current namespace, or nil
46
+ # when no such route exists. e.g. `belongs_to :owner` -> `options_admin_users_path`.
47
+ def options_url(klass)
48
+ return unless klass
49
+ helper = [ :options, *@template.namespace, klass.model_name.route_key, :path ].join("_")
50
+ @template.public_send(helper) if @template.respond_to?(helper)
51
+ end
52
+
53
+ # The capped option collection, guaranteeing the currently-associated record is
54
+ # among the options even when it sorts past the cap — otherwise editing a record
55
+ # whose association is beyond `max_collection_options` would silently drop the value.
56
+ def with_selected(info)
57
+ collection = info.collection
58
+ return collection unless info.reflection&.belongs_to?
59
+
60
+ selected = object.try(info.reflection.name)
61
+ return collection unless selected
62
+
63
+ records = collection.to_a
64
+ records.include?(selected) ? records : [ selected, *records ]
65
+ end
66
+
67
+ # An ActiveRecord enum renders as a plain <select> of its declared values,
68
+ # read straight off `defined_enums` (see FieldInfo#values).
69
+ def enum(method, choices = nil, **options)
70
+ info = info(method)
71
+
72
+ choices ||= info.values
24
73
  options[:include_blank] ||= info.prompt
25
74
 
26
- input(info.input_key, collection, :id, -> { @template.present(_1).title }, as: :collection_select, **options)
75
+ select(method, choices, options)
27
76
  end
28
77
 
29
78
  def hidden(*methods, **, &)
@@ -52,8 +101,14 @@ module CafeCar
52
101
 
53
102
  options[:placeholder] = info.placeholder unless options.key?(:placeholder)
54
103
  options[:autocomplete] = info.autocomplete unless options.key?(:autocomplete)
104
+ options[:multiple] = true if as == :file_field && info.multiple? && !options.key?(:multiple)
105
+
106
+ # Field-typed inputs render through their component object; explicit `as:`
107
+ # overrides the family doesn't own (e.g. `hidden_field`) fall through to the
108
+ # helper directly, preserving `#input`'s "render via any form helper" contract.
109
+ return public_send(as, method, *args, **options) unless Inputs::BaseInput.classes.key?(as)
55
110
 
56
- public_send(as, method, *args, **options)
111
+ Inputs::BaseInput.build(as, form: self, method:, template: @template, args:, **options).to_html
57
112
  end
58
113
 
59
114
  def hint(method, text = info(method).hint, **)
@@ -94,7 +149,7 @@ module CafeCar
94
149
  end
95
150
  end
96
151
 
97
- def remaining_attributes = policy.editable_attributes - @fields.keys
152
+ def remaining_attributes = policy.attributes.editable - @fields.keys
98
153
 
99
154
  def remaining_fields(**, &block)
100
155
  block ||= proc { field(_1, **) }
@@ -33,6 +33,14 @@ module CafeCar
33
33
 
34
34
  def body_classes = [ *controller_path.split(?/), action_name, *@body_class ]
35
35
 
36
+ # Stylesheet <link> for the active bundled theme (`CafeCar.theme`), injected
37
+ # into every CafeCar page's <head>. Each theme is self-contained — its light
38
+ # tokens plus a `prefers-color-scheme: dark` block — so dark mode needs no
39
+ # separate tag. Emitted after `application.css` so its `:root` tokens win.
40
+ def theme_stylesheet_tag
41
+ stylesheet_link_tag "cafe_car/themes/#{CafeCar.theme}", "data-turbo-track": "reload"
42
+ end
43
+
36
44
  def title(object)
37
45
  present(object).title.presence.tap do |title|
38
46
  content_for(:title, title)
@@ -81,6 +89,81 @@ module CafeCar
81
89
  # the export matches the view. Pagination is dropped (CSV exports the full set).
82
90
  def csv_url = url_for(request.params.except("page", "per").merge(format: :csv))
83
91
 
92
+ # The active-filter chips above the index: one per leaf in the policy-gated
93
+ # filter set (permitted_filter_params), each carrying its human label, display
94
+ # value, and the URL that removes just that one filter — the current params
95
+ # minus its single key, so every OTHER filter plus q/sort/view survive.
96
+ # Reflecting the gated set (not raw request params) means a stray or
97
+ # non-permitted param never grows a chip.
98
+ def active_filters(klass = model)
99
+ flatten_filters(permitted_filter_params).map do |key, value|
100
+ { key:, label: filter_label(klass, key), value: filter_value(klass, key, value),
101
+ remove_url: url_for(request.params.except(key, "page")) }
102
+ end
103
+ end
104
+
105
+ # "Clear all" target: the current params minus every active filter key (and
106
+ # the now-stale page), keeping the control params — q, sort, view, per.
107
+ def clear_filters_url
108
+ url_for(request.params.except(*flatten_filters(permitted_filter_params).keys, "page"))
109
+ end
110
+
111
+ # Flattens the nested gated filter params back to the flat dot-keyed params
112
+ # request.params carries: every nested hash — an operator group (`{min:}`) or
113
+ # an association hop (`{client: {status:}}`) — recurses, joining segments with
114
+ # ".", so a leaf's key is exactly the one url_for drops to remove it. Non-hash
115
+ # leaves (a scalar, an id set, a parsed Range) end the walk.
116
+ def flatten_filters(params, prefix = "")
117
+ params.each_with_object({}) do |(key, value), flat|
118
+ path = "#{prefix}#{key}"
119
+ value.is_a?(Hash) ? flat.merge!(flatten_filters(value, "#{path}.")) : flat[path] = value
120
+ end
121
+ end
122
+
123
+ # The human label for a filter key, from the same Filter::FieldInfo the panel
124
+ # controls read (policy/locale-driven, with a humanized-attribute fallback) —
125
+ # never the raw column/operator key.
126
+ def filter_label(klass, key)
127
+ CafeCar["Filter::FieldInfo"].new(model: klass, method: filter_method(key)).label
128
+ end
129
+
130
+ # A filter key reduced to the attribute path its label reads: the trailing
131
+ # operator char (`name~`) and a trailing word-operator segment (`price.min`)
132
+ # drop off; a nested association path (`client.status`) keeps its dots.
133
+ def filter_method(key)
134
+ segments = key.to_s.sub(/\W+$/, "").split(".")
135
+ segments.pop if segments.size > 1 && CafeCar::QueryBuilder::OPS.key?(segments.last)
136
+ segments.join(".")
137
+ end
138
+
139
+ # A filter value as chip text. An association filter — a belongs_to foreign-key
140
+ # set (`owner_id[]`) or a has_many/has_one membership set (`line_items.id[]`) —
141
+ # shows each referenced record's TITLE (the same title the filter's typeahead
142
+ # lists), so a chip reads "Owner: Jane Doe" not "Owner: 42". The whole set is
143
+ # resolved in ONE query, and an id that no longer resolves falls back to its raw
144
+ # value. Every other filter (enum/string/range/boolean) prints its value(s) as-is.
145
+ def filter_value(klass, key, value)
146
+ ids = Array.wrap(value)
147
+ assoc = filter_association(klass, key)
148
+ return ids.join(", ") unless assoc
149
+
150
+ titles = assoc.where(id: ids).index_by { _1.id.to_s }
151
+ ids.map { |id| titles[id.to_s]&.then { present(_1).title } || id }.join(", ")
152
+ end
153
+
154
+ # The model whose records an association filter's values are ids of, or nil for
155
+ # a non-association filter. A foreign-key set resolves through the terminal
156
+ # belongs_to; a membership set (`<assoc>.id[]`) through the association named
157
+ # just before the `.id`. Reflects through the same Filter::FieldInfo the panel
158
+ # controls read, so titling never diverges from what the control's select shows.
159
+ def filter_association(klass, key)
160
+ method = filter_method(key)
161
+ parent, _, leaf = method.rpartition(".")
162
+ method = parent if leaf == "id" && parent.present?
163
+ field = CafeCar["Filter::FieldInfo"].new(model: klass, method:)
164
+ field.reflection&.klass if field.type.in?(%i[belongs_to has_many has_one])
165
+ end
166
+
84
167
  def context(name = nil, &)
85
168
  @context ||= []
86
169
 
@@ -143,6 +226,119 @@ module CafeCar
143
226
  CafeCar[:TableBuilder].new(self, objects:, **options, &block)
144
227
  end
145
228
 
229
+ def chart_for(objects, **options)
230
+ CafeCar[:ChartBuilder].new(self, objects:,
231
+ column: params[:chart_x], bucket: params[:chart_by], metric: params[:chart_y], **options)
232
+ end
233
+
234
+ # The bulk actions offered on this model's index table — the model policy's
235
+ # `permitted_bulk_actions` list is the source of truth. This only decides which
236
+ # buttons show; each selected row is still authorized one-by-one in the
237
+ # controller, so a shown action never bulk-bypasses a per-record denial.
238
+ def bulk_actions(klass = model)
239
+ policy(klass.new).attributes.actions.bulk
240
+ end
241
+
242
+ def bulk_actions? = bulk_actions.any?
243
+
244
+ # A single bulk-action submit button, wired to the table's BulkForm from the
245
+ # toolbar (`form: "BulkForm"`). The label comes from the locale (`en.destroy`),
246
+ # never a hardcoded string, and the button style from the locale too — a shipped
247
+ # default maps `destroy` to `:danger` (see `bulk_actions.styles`). The default
248
+ # `_bulk_actions` partial loops `bulk_actions` calling this; a host overriding
249
+ # the partial can call it directly for a bespoke set.
250
+ def bulk_action(name, style = bulk_action_style(name))
251
+ label = t(name, default: name.to_s.humanize)
252
+ confirm = t(:bulk_confirm, scope: :helpers, action: label)
253
+ Button(*style, tag: :button, type: :submit, form: "BulkForm",
254
+ name: :bulk_action, value: name, data: { turbo_confirm: confirm }) { label }
255
+ end
256
+
257
+ # The button style (a Button flag like `:danger`) for a bulk action, from the
258
+ # locale under `bulk_actions.styles`, or nil for the default style.
259
+ def bulk_action_style(name)
260
+ t("bulk_actions.styles.#{name}", default: nil)&.to_sym
261
+ end
262
+
263
+ # The custom collection actions offered in this model's index toolbar — the
264
+ # model policy's `permitted_collection_actions` list is the source of truth,
265
+ # like #bulk_actions. This only decides which buttons show; the controller
266
+ # re-authorizes the POST (see Controller#collection_action).
267
+ def collection_actions(klass = model)
268
+ policy(klass.new).attributes.actions.collection
269
+ end
270
+
271
+ # A collection-action button for the index toolbar: POSTs the named action
272
+ # to the generic collection route (Controller#collection_action), carrying the
273
+ # active filters + search so the action runs over exactly the viewed set. The
274
+ # label appends that set's count (localized `helpers.collection_action` — e.g.
275
+ # "Publish all 21"); style and confirm come from the locale like #bulk_action.
276
+ def collection_action(name, style = action_style(name))
277
+ count = filtered_scope.count
278
+ action = t(name, default: name.to_s.humanize)
279
+ label = t(:collection_action, scope: :helpers, action:, count:)
280
+ confirm = t(:collection_confirm, scope: :helpers, action:, count:,
281
+ models: model.model_name.human(count:).downcase)
282
+ filters = search_term ? filter_params.merge(q: search_term) : filter_params
283
+ url = url_for(filters.merge(action: :collection_action, collection_action: name))
284
+ button_to label, url, class: ui.Button(*style).class_name, data: { turbo_confirm: confirm }
285
+ end
286
+
287
+ # The button style for a custom (member or collection) action, from the
288
+ # locale under `actions.styles` — the same convention as #bulk_action_style.
289
+ def action_style(name)
290
+ t("actions.styles.#{name}", default: nil)&.to_sym
291
+ end
292
+
293
+ # A dashboard metric tile: a label over the value captured from the block. The
294
+ # block is host-authored (a real view), evaluated at render.
295
+ def metric(label, &block)
296
+ render "cafe_car/dashboard/metric", label:, value: capture(&block)
297
+ end
298
+
299
+ # A dashboard chart tile: a title over the dependency-free inline-SVG bar chart,
300
+ # built from `model`'s records bucketed over the `x` date column at `by`
301
+ # granularity. `x` runs through ChartBuilder's date-column allowlist unchanged,
302
+ # so a column name can never reach SQL raw.
303
+ def chart(title, model:, x:, by: nil)
304
+ render "cafe_car/dashboard/chart", title:, objects: model.all, x:, by:
305
+ end
306
+
307
+ # The policy-driven metric tiles for `model`: one count tile per name in the
308
+ # model policy's `permitted_metrics` (`:all` = the whole relation). This is the
309
+ # default dashboard behavior — a host template overrides by calling `metric`
310
+ # directly instead.
311
+ def metrics(model)
312
+ policy = policy(model.new)
313
+ safe_join(policy.permitted_metrics.map { metric_for(model, _1) })
314
+ end
315
+
316
+ def metric_for(model, name)
317
+ scope = name.to_sym == :all ? model.all : model.public_send(name)
318
+ metric(metric_label(model, name)) { scope.count }
319
+ end
320
+
321
+ # A metric tile's label, from the locale: the pluralized model name for the
322
+ # whole-relation `:all` metric, else `metrics.<name>` (defaulting to the
323
+ # humanized scope name).
324
+ def metric_label(model, name)
325
+ return model.model_name.human(count: 2) if name.to_sym == :all
326
+ t("metrics.#{name}", default: name.to_s.humanize)
327
+ end
328
+
329
+ # Wraps the index table in the form that submits the selected row ids and the
330
+ # chosen bulk action. With no bulk actions available, the table renders as-is.
331
+ # The action bar itself lives up in the index toolbar (next to the search box);
332
+ # its buttons reach back into this form by id via their `form="BulkForm"`.
333
+ def bulk_form(&block)
334
+ content = capture(&block)
335
+ return content unless bulk_actions?
336
+
337
+ form_tag(url_for(action: :batch), method: :post, class: "BulkForm", id: "BulkForm") do
338
+ content
339
+ end
340
+ end
341
+
146
342
  def debug? = params.key?(:debug)
147
343
  def console? = params.key?(:console)
148
344
 
@@ -0,0 +1,11 @@
1
+ module CafeCar
2
+ module Inputs
3
+ # A `<select>` over an association's records (belongs_to / has_many). Delegates
4
+ # to the form builder's `association`, which caps the collection, guarantees the
5
+ # currently-associated record is present, and flags the select for typeahead
6
+ # enhancement when the associated model exposes an options feed.
7
+ class AssociationInput < BaseInput
8
+ def helper = :association
9
+ end
10
+ end
11
+ end
@@ -1,19 +1,68 @@
1
1
  module CafeCar
2
2
  module Inputs
3
+ # Renders one bound form input for a field.
4
+ #
5
+ # Each field type is a small subclass that names the form-builder helper it emits
6
+ # through (and any per-type options). `.build` maps a field's resolved input key
7
+ # (what `FieldInfo#input` returns) to the right subclass, so *which* input a field
8
+ # gets stays single-sourced on the field metadata — these objects only render it.
9
+ #
10
+ # Styling rides the shared `ui/Input.css` (native input/select/textarea), and copy
11
+ # (placeholder, prompt, hint) comes from the field's locale via `FieldInfo` — no
12
+ # hardcoded strings, no styles outside components.
3
13
  class BaseInput
4
- attr_accessor :options
14
+ # Maps a resolved input key (`FieldInfo#input`) to its component class. Kept
15
+ # lazy so the subclass constants resolve through Zeitwerk at call time.
16
+ def self.classes
17
+ {
18
+ text_field: StringInput,
19
+ text_area: TextAreaInput,
20
+ number_field: NumberInput,
21
+ check_box: BooleanInput,
22
+ date_field: DateInput,
23
+ datetime_field: DatetimeInput,
24
+ password_field: PasswordInput,
25
+ file_field: FileInput,
26
+ rich_text_area: RichTextInput,
27
+ enum: SelectInput,
28
+ association: AssociationInput,
29
+ fields_for: NestedInput
30
+ }
31
+ end
32
+
33
+ def self.build(key, **, &)
34
+ klass = classes[key] or raise ArgumentError, "No input component for #{key.inspect}"
35
+ klass.new(**, &)
36
+ end
5
37
 
6
- def initialize(template:, **options)
38
+ attr_reader :form, :method, :template, :args, :options
39
+
40
+ def initialize(form:, method:, template:, args: [], **options, &block)
41
+ @form = form
42
+ @method = method
7
43
  @template = template
44
+ @args = args
8
45
  @options = options
46
+ @block = block
9
47
  end
10
48
 
11
- def tag = :input
12
- def type = :text
49
+ def info = form.info(method)
13
50
 
14
- def to_html
15
- @template.Input(tag:, type:, **options)
16
- end
51
+ # The form-builder helper this input renders through. Subclasses override.
52
+ def helper = :text_field
53
+
54
+ # Per-type option defaults, filled only where the caller left them unset.
55
+ def defaults = {}
56
+
57
+ # Locale-driven copy for free-text inputs — never a hardcoded string.
58
+ def text_hints = { placeholder: info.placeholder, autocomplete: info.autocomplete }.compact
59
+
60
+ def render_options = defaults.merge(options)
61
+
62
+ def to_html = form.public_send(helper, method, *args, **render_options, &@block)
63
+
64
+ def to_s = to_html.to_s
65
+ def html_safe? = true
17
66
  end
18
67
  end
19
68
  end
@@ -0,0 +1,9 @@
1
+ module CafeCar
2
+ module Inputs
3
+ # A native `<input type="checkbox">` (with Rails' companion hidden field) for
4
+ # boolean columns, themed as a CafeCar control by `ui/Input.css`.
5
+ class BooleanInput < BaseInput
6
+ def helper = :check_box
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ module CafeCar
2
+ module Inputs
3
+ # A native date picker `<input type="date">` for date columns.
4
+ class DateInput < BaseInput
5
+ def helper = :date_field
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module CafeCar
2
+ module Inputs
3
+ # A native `<input type="datetime-local">` for datetime columns.
4
+ class DatetimeInput < BaseInput
5
+ def helper = :datetime_field
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,11 @@
1
+ module CafeCar
2
+ module Inputs
3
+ # A file picker `<input type="file">` for Active Storage attachments. A
4
+ # `has_many_attached` field renders a multiple picker so several files upload
5
+ # at once.
6
+ class FileInput < BaseInput
7
+ def helper = :file_field
8
+ def defaults = info.multiple? ? { multiple: true } : {}
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ module CafeCar
2
+ module Inputs
3
+ # The `fields_for` sub-form for a `accepts_nested_attributes_for` association.
4
+ # Delegates to the form builder's `fields_for`, which renders each child record's
5
+ # remaining fields (or the caller's block).
6
+ class NestedInput < BaseInput
7
+ def helper = :fields_for
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ module CafeCar
2
+ module Inputs
3
+ # A numeric `<input type="number">` for integer columns.
4
+ class NumberInput < BaseInput
5
+ def helper = :number_field
6
+ def defaults = text_hints
7
+ end
8
+ end
9
+ end
@@ -1,7 +1,9 @@
1
1
  module CafeCar
2
2
  module Inputs
3
- class StringInput < BaseInput
4
- def tag = :input
3
+ # A masked `<input type="password">` for `has_secure_password` digests.
4
+ class PasswordInput < BaseInput
5
+ def helper = :password_field
6
+ def defaults = text_hints
5
7
  end
6
8
  end
7
9
  end
@@ -0,0 +1,9 @@
1
+ module CafeCar
2
+ module Inputs
3
+ # An Action Text `<trix-editor>` (with its hidden input) for `has_rich_text`
4
+ # associations.
5
+ class RichTextInput < BaseInput
6
+ def helper = :rich_text_area
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ module CafeCar
2
+ module Inputs
3
+ # A `<select>` of an ActiveRecord enum's declared values. Delegates to the form
4
+ # builder's `enum`, which reads the choices off `defined_enums` and prepends the
5
+ # locale prompt as the blank option.
6
+ class SelectInput < BaseInput
7
+ def helper = :enum
8
+ end
9
+ end
10
+ end
@@ -1,7 +1,9 @@
1
1
  module CafeCar
2
2
  module Inputs
3
+ # A free-text `<input type="text">` — the default for string/decimal columns.
3
4
  class StringInput < BaseInput
4
- def type = :text
5
+ def helper = :text_field
6
+ def defaults = text_hints
5
7
  end
6
8
  end
7
9
  end
@@ -0,0 +1,9 @@
1
+ module CafeCar
2
+ module Inputs
3
+ # A multi-line `<textarea>` for text/json columns.
4
+ class TextAreaInput < BaseInput
5
+ def helper = :text_area
6
+ def defaults = text_hints
7
+ end
8
+ end
9
+ end
@@ -45,6 +45,17 @@ module CafeCar
45
45
  end
46
46
  end
47
47
 
48
+ # A custom member action (Policy#permitted_member_actions) as a POST link —
49
+ # label, i18n and disable state (the `name?` predicate, via #cant?) all
50
+ # derive from the action name, like #destroy. The `params` override the
51
+ # route action: the href hits the generic member-action endpoint
52
+ # (Controller#member_action), which re-authorizes and forwards to the
53
+ # record's `name!`.
54
+ def action(name, *, **opts, &)
55
+ link(name, @object, *, method: :post,
56
+ params: { action: :member_action, member_action: name }, **opts, &)
57
+ end
58
+
48
59
  def show(*, **, &) = link(:show, @object, *, data: { turbo_stream: nil }, **, &)
49
60
  def edit(...) = link(:edit, @object, ...)
50
61
  def destroy(*, **, &) = link(:destroy, @object, *, method: :delete, confirm: confirm(:destroy), **, &)
@@ -4,20 +4,49 @@ module CafeCar::Model
4
4
  include CafeCar::Queryable
5
5
  include CafeCar::Informable
6
6
 
7
+ # A validated sort term: an Arel/hash `order` argument plus the association to
8
+ # `left_outer_joins` (nil for a plain column) so the qualified ORDER BY resolves.
9
+ SortKey = Data.define(:order, :join)
10
+
7
11
  class_methods do
8
12
  def sorted(*args)
9
- return all if args.compact_blank!.empty?
10
- args = args.flat_map { normalize_sort_key(_1) }
11
- reorder(*args)
13
+ keys = args.join(",").split(",").filter_map { normalize_sort_key(_1) }
14
+ return all if keys.empty?
15
+ scope = reorder(*keys.map(&:order))
16
+ joins = keys.filter_map(&:join).uniq
17
+ joins.any? ? scope.left_outer_joins(*joins) : scope
12
18
  end
13
19
 
20
+ # Turn one requested sort key into a safe SortKey, or nil to drop it. Honors a
21
+ # leading "-" for descending. Every key is validated against the model's real
22
+ # columns/associations before it can reach `reorder` — a client-supplied
23
+ # `?sort=` (e.g. `item.`, `bogus.col`, or a raw SQL fragment) can never pass
24
+ # through as SQL, where it would trip Rails' dangerous-query guard into an
25
+ # unauthenticated 500.
14
26
  def normalize_sort_key(key)
15
- case key
16
- when /,/
17
- key.split(",").map { normalize_sort_key _1 }
18
- when /^-(.+)$/ then { $1 => :desc }
19
- else key
27
+ desc = key.to_s.start_with?("-")
28
+ name = key.to_s.delete_prefix("-").strip
29
+ direction = desc ? :desc : :asc
30
+
31
+ if name.include?(".")
32
+ association_sort_key(name, direction)
33
+ elsif column_names.include?(name)
34
+ SortKey.new(order: { name => direction }, join: nil)
20
35
  end
21
36
  end
37
+
38
+ # A `<belongs_to>.<column>` header-sort key (LabelBuilder only emits these for
39
+ # belongs_to). Qualifies the ORDER BY to the *reflected* table so custom table
40
+ # names/aliases work, and joins the association in so the column resolves.
41
+ # Strict: an unknown/polymorphic association or unknown column returns nil.
42
+ def association_sort_key(name, direction)
43
+ assoc, column = name.split(".", 2)
44
+ reflection = reflect_on_association(assoc.to_sym)
45
+ return unless reflection&.belongs_to? && !reflection.polymorphic?
46
+ return unless reflection.klass.column_names.include?(column)
47
+
48
+ order = reflection.klass.arel_table[column].public_send(direction)
49
+ SortKey.new(order:, join: reflection.name)
50
+ end
22
51
  end
23
52
  end
@@ -43,6 +43,16 @@ module CafeCar
43
43
 
44
44
  def router = Rails.application.routes.router
45
45
  def named_routes = Rails.application.routes.named_routes.to_h.values.map { Route.new(_1, template: @template) }
46
+
47
+ # Path to the opt-in dashboard overview, or nil until a host writes the
48
+ # dashboard template (`app/views/cafe_car/dashboard/show.html.haml`) — its
49
+ # existence is the opt-in. The dashboard route lives in the engine (not the
50
+ # host's routes), so it's resolved through the engine's own url helpers — that
51
+ # works from any host controller regardless of where CafeCar is mounted.
52
+ def dashboard_href
53
+ return unless @template.lookup_context.exists?("show", %w[cafe_car/dashboard], false)
54
+ CafeCar::Engine.routes.url_helpers.dashboard_path
55
+ end
46
56
  def index_routes = named_routes.select(&:index?)
47
57
  def groups = routes.group_by(&:group)
48
58