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
@@ -1,20 +1,105 @@
1
1
  module CafeCar::Controller::Filtering
2
2
  extend ActiveSupport::Concern
3
3
 
4
+ # URL params CafeCar consumes for control flow (sorting, pagination, view
5
+ # switching, keyword search) plus Rails' routing/form internals. Everything
6
+ # else on an index request is treated as a filter, so a bare `?price.min=10`
7
+ # or `?name=Widget` reaches the query DSL without a namespacing prefix.
8
+ CONTROL_PARAMS = %w[
9
+ controller action format id
10
+ sort page per view tab q chart_x chart_y chart_by
11
+ _method authenticity_token commit utf8 button _
12
+ ].freeze
13
+
4
14
  included do
5
- helper_method :parsed_params, :dot_params, :filtered?, :search_term
15
+ helper_method :parsed_params, :filter_params, :permitted_filter_params,
16
+ :filtered?, :search_term, :filtered_scope
6
17
  end
7
18
 
8
19
  private
9
20
 
10
21
  def filtered(scope)
11
- scope.query([ parsed_params[""], search_term ].compact_blank)
22
+ scope.query([ permitted_filter_params, search_term ].compact_blank)
23
+ end
24
+
25
+ # The policy-scoped collection narrowed by the active filters + keyword search
26
+ # — the set the index is showing, before sorting/pagination. The single source
27
+ # of truth for "the viewed scope": #scope sorts + paginates it for display, a
28
+ # collection action runs its bang over exactly this set (Controller#collection_action),
29
+ # and the toolbar button counts it (Helpers#collection_action).
30
+ def filtered_scope(klass = model)
31
+ filtered(policy_scope(klass))
12
32
  end
13
33
 
14
34
  def filtered?
15
- parsed_params[""].present? || search_term.present?
35
+ permitted_filter_params.present? || search_term.present?
36
+ end
37
+
38
+ # The filter params the policy permits — the enforcement half of the
39
+ # policy-is-source-of-truth pattern (Policy#permitted_filters /
40
+ # #permitted_scopes). Keys the policy doesn't list are silently dropped: a
41
+ # stray or hostile param just doesn't filter (no 400, and it never reaches
42
+ # the query DSL where an unknown key would raise).
43
+ def permitted_filter_params
44
+ @permitted_filter_params ||= prune_filters(policy(model.new), model, parsed_params[""] || {})
45
+ end
46
+
47
+ # Recursively keep only filter params whose FULL dot-path the policy permits.
48
+ # An association keyed to a nested hash (`{client: {status: ...}}`, not an
49
+ # operator group like `{min:, max:}`) is descended into the far model, extending
50
+ # the path; every other entry is a leaf validated at its full path. A crafted
51
+ # `?client.owner.secret=` — an undeclared nested path — is pruned here, before
52
+ # the query DSL builds any join. The security guarantee is identical to the
53
+ # top-level gate, just path-aware: the FULL path must clear #permitted_filter?.
54
+ def prune_filters(policy, klass, params, prefix = "")
55
+ params.each_with_object({}) do |(key, value), kept|
56
+ base = key.to_s.sub(/\W+$/, "")
57
+ ref = klass.reflect_on_association(base)
58
+ if ref && value.is_a?(Hash) && !operator_params?(value)
59
+ nested = prune_filters(policy, ref.klass, value, "#{prefix}#{base}.")
60
+ kept[key] = nested if nested.present?
61
+ elsif permits_filter?(policy, klass, "#{prefix}#{base}", base)
62
+ kept[key] = value
63
+ end
64
+ end
65
+ end
66
+
67
+ # Classify a filter key the way QueryBuilder#param! does — on the base name,
68
+ # operator suffix stripped (`price>` → price, `title~` → title): a known
69
+ # attribute or association checks #permitted_filters at its full (possibly
70
+ # nested) path, anything else is a would-be scope and checks #permitted_scopes
71
+ # (scopes are top-level only, so `base` alone is right there).
72
+ def permits_filter?(policy, klass, path, base)
73
+ if klass.reflect_on_association(base) || klass.columns_hash.key?(base)
74
+ policy.permitted_filter?(path)
75
+ else
76
+ policy.permitted_scope?(base)
77
+ end
78
+ end
79
+
80
+ # A filter value whose keys are ALL comparison operators (`{min:, max:}`) is a
81
+ # terminal operator group, not a nested-association hash — the same test
82
+ # QueryBuilder#operators? makes before desugaring it to a range.
83
+ def operator_params?(hash)
84
+ hash.any? && hash.keys.all? { CafeCar::QueryBuilder::OPS.key?(_1.to_s) }
85
+ end
86
+
87
+ # The `?sort=` keys the policy permits — the sort half of the same gate that
88
+ # guards filtering. Ordering exposes a column's values as surely as filtering
89
+ # does, so a key is honored only when its base attribute clears
90
+ # #permitted_filter?; a non-permitted or hidden column (e.g. password_digest,
91
+ # or an attribute a policy leaves off #permitted_filters) is dropped before it
92
+ # reaches Model.sorted. A leading "-" (descending) and an `assoc.column`
93
+ # association-sort suffix are stripped to find the base name.
94
+ def permitted_sort
95
+ policy = policy(model.new)
96
+ sort_keys.select { |key| policy.permitted_filter?(sort_base(key)) }
16
97
  end
17
98
 
99
+ def sort_keys = params[:sort].to_s.split(",").filter_map { _1.strip.presence }
100
+
101
+ def sort_base(key) = key.delete_prefix("-").split(".", 2).first
102
+
18
103
  # Keyword term from the index search box. Read raw (not through ParamParser) so a
19
104
  # term keeps its literal text, then funneled into the query DSL as a bare String
20
105
  # that routes to `QueryBuilder#search!` alongside the dot-filters. Only a String
@@ -22,16 +107,33 @@ module CafeCar::Controller::Filtering
22
107
  # (otherwise the non-String would reach the query DSL and raise a 500).
23
108
  def search_term = (params[:q] if params[:q].is_a?(String)).presence
24
109
 
25
- def dot_params
26
- request.params.slice(*request.params.keys.grep(/^\./))
110
+ # Raw (unparsed) filter params — every request param that isn't a control
111
+ # param — for round-tripping the active filter into hidden form fields so a
112
+ # search or sort resubmission keeps it.
113
+ def filter_params
114
+ request.params.except(*CONTROL_PARAMS)
27
115
  end
28
116
 
117
+ # Filters always ride the URL, never a request body: on an index GET the whole
118
+ # param set is filters; on a collection-action POST they ride the query string
119
+ # (see Helpers#collection_action). A model-mutation POST (create/update) has no
120
+ # query string, so its body stays raw — it never reads parsed_params anyway.
29
121
  def parsed_params
30
122
  @parsed_params ||=
31
123
  if request.get? || request.head?
32
- CafeCar::ParamParser.new(request.params).parsed
124
+ parse(request.params)
125
+ elsif request.query_parameters.present?
126
+ parse(request.query_parameters)
33
127
  else
34
128
  request.params
35
129
  end
36
130
  end
131
+
132
+ # Nest dot-keyed params (`price.min` → `{price: {min:}}`) via ParamParser, then
133
+ # stash the filter subset (control params removed) under "" for
134
+ # #permitted_filter_params.
135
+ def parse(params)
136
+ parsed = CafeCar::ParamParser.new(params).parsed
137
+ parsed.merge("" => parsed.except("", *CONTROL_PARAMS))
138
+ end
37
139
  end
@@ -12,8 +12,14 @@ module CafeCar
12
12
  define_method(:model) { @model ||= model }
13
13
  end
14
14
 
15
- def default_view(v = @default_view || "table")
16
- @default_view = v.to_s
15
+ # The index view (`table`/`grid`/`chart`) shown when no `?view=` is given.
16
+ # `default_view :grid` sets it; a bare call reads the current value. Backed
17
+ # by an inheritable class_attribute (`_default_view`) so a subclass keeps
18
+ # its parent's setting — a bare class ivar would not carry across
19
+ # subclassing.
20
+ def default_view(view = nil)
21
+ self._default_view = view.to_s if view
22
+ _default_view
17
23
  end
18
24
 
19
25
  def cafe_car(only: nil, except: nil, model: nil)
@@ -36,11 +42,16 @@ module CafeCar
36
42
 
37
43
  before_action :set_current_attributes
38
44
 
39
- before_action :find_object, only: _only.(%i[show edit update destroy])
45
+ before_action :find_object, only: _only.(%i[show edit update destroy member_action])
40
46
  before_action :build_object, only: _only.(%i[new create])
41
47
  before_action :find_objects, only: _only.(%i[index])
42
48
  before_action :assign_attributes, only: _only.(%i[create update])
43
- before_action :authorize!
49
+ # `batch`, `options` and the custom-action endpoints are excluded: each
50
+ # authorizes on its own — `batch` per selected record (see #batch),
51
+ # `options` via `index?` plus the policy scope on the typeahead feed
52
+ # (see #options), `member_action`/`collection_action` via the named
53
+ # action's own policy predicate.
54
+ before_action :authorize!, except: %i[batch options member_action collection_action]
44
55
 
45
56
  after_action :verify_authorized, :verify_policy_scoped
46
57
  end
@@ -71,6 +82,11 @@ module CafeCar
71
82
  self.responder = CafeCar[:ApplicationResponder]
72
83
  default_form_builder CafeCar[:FormBuilder]
73
84
 
85
+ # Inheritable backing for `default_view` (see the class method) — a
86
+ # class_attribute carries the setting to subclasses; a bare class ivar
87
+ # would not.
88
+ class_attribute :_default_view, default: "table", instance_accessor: false
89
+
74
90
  define_callbacks_with_helpers :render, :update, :create, :destroy
75
91
  add_flash_types :success, :warning, :error
76
92
 
@@ -101,6 +117,86 @@ module CafeCar
101
117
  respond_with object
102
118
  end
103
119
 
120
+ # Apply a bulk action, named by `params[:bulk_action]`, to the selected records.
121
+ # The action name is derived, not registered: the model policy's
122
+ # `permitted_bulk_actions` is the whitelist (a name outside it is a bad request),
123
+ # `name?` is the per-record authorization predicate, and `name!` the model bang
124
+ # method applied. Every record is authorized ON ITS OWN — the candidate set is
125
+ # first narrowed to the policy scope (rows the user may see), then each is checked
126
+ # against `name?`; unauthorized rows are skipped, never bulk-bypassed. That
127
+ # per-record check is the security boundary, so Pundit's blanket
128
+ # `verify_authorized` is satisfied by `skip_authorization` after the fact.
129
+ def batch
130
+ skip_authorization # authorization is per-record below, not one blanket check
131
+ action = permitted_bulk_action(params[:bulk_action])
132
+ unless action
133
+ skip_policy_scope # no candidate query on the reject path
134
+ return head(:bad_request)
135
+ end
136
+
137
+ records = policy_scope(model).where(id: Array(params[:ids]))
138
+ batched = records.select { |record| action_allowed?(record, action) }
139
+ batched.each { |record| record.public_send("#{action}!") }
140
+
141
+ redirect_to url_for(action: :index), success: batch_notice(action, batched.size)
142
+ end
143
+
144
+ # Run a policy-declared custom action on one record:
145
+ # POST /<resources>/:id/actions/:member_action. The name resolves through the
146
+ # model policy's `permitted_member_actions` whitelist (anything else is a
147
+ # 404), its `name?` predicate authorizes, then — by convention — the record's
148
+ # `name!` bang method runs. A host overrides the behavior by defining a
149
+ # public controller method of the action's name; it takes over after
150
+ # authorization and owns the response.
151
+ def member_action
152
+ skip_authorization # authorized via the action's own predicate below
153
+ name = permitted_custom_action(params[:member_action], policy(object).attributes.actions.member)
154
+ return head(:not_found) unless name
155
+
156
+ authorize_action! object, name
157
+ return public_send(name) if respond_to?(name)
158
+
159
+ object.public_send("#{name}!")
160
+ redirect_back_or_to href_for(object), success: action_notice(:member_action, name)
161
+ end
162
+
163
+ # Run a policy-declared custom action over the collection:
164
+ # POST /<resources>/actions/:collection_action. Same derivation as
165
+ # #member_action — `permitted_collection_actions` whitelists, `name?` (asked
166
+ # of the model class) authorizes — then `name!` runs on the #filtered_scope,
167
+ # which ActiveRecord delegates to a class method within that scoping. It runs
168
+ # over the currently-viewed, filtered set (the button carries the active
169
+ # filters, its label shows the count) — "Publish all" acts on exactly the
170
+ # records the user is looking at. A host override (a public controller method
171
+ # of the action's name) scopes its own query.
172
+ def collection_action
173
+ skip_authorization # authorized via the action's own predicate below
174
+ name = permitted_custom_action(params[:collection_action], policy(model.new).attributes.actions.collection)
175
+ unless name
176
+ skip_policy_scope # no query on the reject path
177
+ return head(:not_found)
178
+ end
179
+
180
+ authorize_action! model, name
181
+ return public_send(name) if respond_to?(name)
182
+
183
+ filtered_scope.public_send("#{name}!")
184
+ redirect_to url_for(action: :index), success: action_notice(:collection_action, name)
185
+ end
186
+
187
+ # JSON typeahead feed for a searchable association select (Tom Select). Returns
188
+ # `[{value, text}]` for the model, filtered by the `?q=` keyword search and
189
+ # capped at `max_collection_options` — so an association field can reach records
190
+ # PAST the render cap. Authorized twice: `index?` gates list access at all, and
191
+ # `policy_scope` narrows rows to those the user may see (never leaking hidden ones).
192
+ def options
193
+ authorize model, :index?
194
+ scope = policy_scope(model)
195
+ scope = scope.query([ search_term ]) if search_term
196
+ records = scope.limit(CafeCar.max_collection_options)
197
+ render json: records.map { |record| { value: record.id, text: option_label(record) } }
198
+ end
199
+
104
200
  def respond_with(*resources, **options, &block)
105
201
  super(*namespace, *resources, **options, &block)
106
202
  end
@@ -117,13 +213,72 @@ module CafeCar
117
213
  flash[:success] = present(object).i18n("#{action_name}_html", scope: :flashes)
118
214
  end
119
215
 
216
+ def permitted_bulk_action(param)
217
+ permitted_custom_action(param, policy(model.new).attributes.actions.bulk)
218
+ end
219
+
220
+ # The whitelisted action name matching `param` — a symbol drawn from the
221
+ # given policy-declared list, or nil for a name outside it. #batch,
222
+ # #member_action and #collection_action send `<name>!`/`<name>?`, so
223
+ # resolving through the whitelist here means the derived method is always a
224
+ # policy-declared name, never the raw request value (a bare param would be
225
+ # a dynamic-send footgun).
226
+ def permitted_custom_action(param, permitted)
227
+ permitted.find { |a| a.to_s == param.to_s }
228
+ end
229
+
230
+ # Whether `subject` (a record, or the model class for collection actions)
231
+ # grants `action` — its `action?` policy predicate. Guarded by `respond_to?`
232
+ # so a permitted action without a predicate simply denies rather than erroring.
233
+ def action_allowed?(subject, action)
234
+ policy = policy(subject)
235
+ predicate = "#{action}?"
236
+ policy.respond_to?(predicate) && policy.public_send(predicate)
237
+ end
238
+
239
+ # #action_allowed?, but raising: one denied custom action is a refusal
240
+ # (rendered by render_unauthorized), not a skippable row like in #batch.
241
+ def authorize_action!(subject, action)
242
+ return if action_allowed?(subject, action)
243
+ raise Pundit::NotAuthorizedError.new(query: "#{action}?", record: subject, policy: policy(subject))
244
+ end
245
+
246
+ # Success flash for a custom action, from the locale —
247
+ # `flashes.member_action` / `flashes.collection_action`, interpolating the
248
+ # action's label (`en.<name>`, humanized fallback) and the model name.
249
+ def action_notice(kind, name)
250
+ label = I18n.t(name, default: name.to_s.humanize)
251
+ I18n.t(kind, scope: :flashes, action: label,
252
+ model: model_name.human.downcase, models: model_name.human(count: 2).downcase)
253
+ end
254
+
255
+ def batch_notice(action, count)
256
+ label = I18n.t(action, default: action.to_s.humanize)
257
+ "#{label} #{count} #{model_name.human(count:).downcase}"
258
+ end
259
+
260
+ # Plain-text label for a typeahead option — the record's policy title attribute
261
+ # (e.g. a user's name), falling back to its id. Kept scalar so the JSON feed
262
+ # never serializes the HTML the presenter's #title emits.
263
+ def option_label(record)
264
+ attribute = policy(record).title_attribute
265
+ record.public_send(attribute).to_s.presence || record.to_s
266
+ end
267
+
120
268
  def sorted(scope)
121
- scope.sorted(*params[:sort].presence)
269
+ scope.sorted(*permitted_sort)
122
270
  end
123
271
 
124
272
  def paginated(scope, page: params[:page], per: params[:per])
125
273
  return scope if request.format.csv? # CSV exports the full filtered+sorted set
126
- scope.page(page).per(per)
274
+ scope.page(page).per(capped_per(per))
275
+ end
276
+
277
+ # Clamp `?per=` to `CafeCar.max_per_page` so an oversized request can't load an
278
+ # unbounded table into memory. A blank `per` falls through to Kaminari's default.
279
+ def capped_per(per)
280
+ return per if per.blank?
281
+ [ per.to_i, CafeCar.max_per_page ].min
127
282
  end
128
283
 
129
284
  def build_object
@@ -146,10 +301,60 @@ module CafeCar
146
301
  object.assign_attributes(permitted_attributes(object))
147
302
  end
148
303
 
149
- def scope = model.all.then { policy_scope _1 }
150
- .then { sorted _1 }
151
- .then { filtered _1 }
152
- .then { paginated _1 }
304
+ # Pundit permits attachment names as scalars, but a `has_many_attached`
305
+ # field posts an array of uploads that a scalar permit silently drops — so
306
+ # only one file (or none) would persist. Expand those keys to `{ name => [] }`
307
+ # so every uploaded file survives strong-params.
308
+ def permitted_attributes(record, action = action_name)
309
+ policy = policy(record)
310
+ method = "permitted_attributes_for_#{action}"
311
+ method = "permitted_attributes" unless policy.respond_to?(method)
312
+ multi = multiple_attachments(record)
313
+ keys = policy.public_send(method).map { |k| multi.include?(k) ? { k => [] } : k }
314
+ pundit_params_for(record).permit(*keys)
315
+ end
316
+
317
+ # Names of the record's `has_many_attached` attachments — the fields whose
318
+ # form submits an array of files.
319
+ def multiple_attachments(record)
320
+ klass = record.is_a?(Class) ? record : record.class
321
+ klass.try(:reflect_on_all_attachments)
322
+ &.select { _1.macro == :has_many_attached }
323
+ &.map { _1.name.to_sym } || []
324
+ end
325
+
326
+ def scope = filtered_scope.then { sorted _1 }
327
+ .then { eager_loaded _1 }
328
+ .then { paginated _1 }
329
+
330
+ # Preload the associations an index will render so a multi-row table costs a
331
+ # bounded number of queries instead of one-per-row-per-association (N+1).
332
+ # With nothing to preload, `.includes` would raise on empty args — skip it.
333
+ def eager_loaded(scope)
334
+ eager_loaded_associations.then { _1.any? ? scope.includes(*_1) : scope }
335
+ end
336
+
337
+ # The displayed belongs_to / has_many associations, each nested with the
338
+ # attachment its preview renders (so the association's own avatar isn't a
339
+ # second-order N+1). Intersecting with `displayable_associations` drops
340
+ # polymorphic ones — which can't be naively `.includes`d — since it already
341
+ # excludes them.
342
+ def eager_loaded_associations
343
+ policy = policy(model.new)
344
+ (policy.attributes.displayable & policy.displayable_associations).map { with_preview(_1) }
345
+ end
346
+
347
+ # `name`, or `{ name => { logo_attachment: :blob } }` when the associated
348
+ # record's preview shows a logo attachment.
349
+ def with_preview(name)
350
+ klass = model.reflect_on_association(name).klass
351
+ logo = policy(klass.new).logo_attribute
352
+ attachment = logo && klass.reflect_on_attachment(logo)
353
+ return name unless attachment
354
+
355
+ plural = attachment.macro == :has_many_attached
356
+ { name => { "#{logo}_attachment#{'s' if plural}": :blob } }
357
+ end
153
358
  def objects = instance_variable_get("@#{model_name.plural}")
154
359
  def objects=(value)
155
360
  instance_variable_set("@#{model_name.plural}", value)
@@ -198,10 +403,14 @@ module CafeCar
198
403
  end
199
404
 
200
405
  def _render_with_renderer_json(obj, options)
406
+ # Plain payloads (e.g. the #options typeahead feed's [{value, text}] array)
407
+ # aren't records — serialize them verbatim rather than deriving model columns.
408
+ return super unless obj.is_a?(CafeCar::Model) || obj.respond_to?(:klass)
409
+
201
410
  # permitted_attributes is record-oriented, so ask a record for the column
202
411
  # list even when serializing a collection.
203
412
  record = obj.is_a?(CafeCar::Model) ? obj : obj.klass.new
204
- options[:only] ||= [ :id ] | policy(record).displayable_attributes
413
+ options[:only] ||= [ :id ] | policy(record).attributes.displayable
205
414
 
206
415
  if obj.is_a?(CafeCar::Model)
207
416
  options[:include] ||= policy(obj).displayable_associations
@@ -83,12 +83,12 @@ module CafeCar
83
83
  require "csv"
84
84
 
85
85
  # CSV export of an index's filtered+sorted collection. Columns mirror the
86
- # JSON renderer's policy-respecting basis (`[:id] | displayable_attributes`)
86
+ # JSON renderer's policy-respecting basis (`[:id] | attributes.displayable`)
87
87
  # so exports never leak attributes the policy hides, narrowed to scalar DB
88
88
  # columns. Associations are out of scope for v1 (only scalar values).
89
89
  ActionController::Renderers.add :csv do |collection, options|
90
90
  klass = collection.respond_to?(:klass) ? collection.klass : collection.class
91
- basis = options[:only] || [ :id ] | policy(klass.new).displayable_attributes
91
+ basis = options[:only] || [ :id ] | policy(klass.new).attributes.displayable
92
92
  columns = basis & klass.column_names.map(&:to_sym)
93
93
  text = columns.select { klass.columns_hash[_1.to_s]&.type.in?(%i[string text]) }
94
94
 
@@ -25,13 +25,20 @@ module CafeCar
25
25
  def associated? = reflection.present?
26
26
  def polymorphic? = reflection&.polymorphic?
27
27
  def digest? = method =~ /_digest$/
28
+ def enum? = model.try(:defined_enums)&.key?(@method.to_s)
29
+ def values = model.defined_enums[@method.to_s].keys
28
30
  def password? = type == :password
29
31
  def rich_text? = reflection&.name =~ /^rich_text_(\w+)$/
30
32
  def attachment? = model.reflect_on_attachment(method)
31
- def collection = reflection.klass.all
33
+ def multiple? = attachment?&.macro == :has_many_attached
34
+ def collection = reflection.klass.limit(CafeCar.max_collection_options)
32
35
  def reflection
33
36
  return if @method.nil?
34
- model.try(:reflect_on_association, @method) || reflections_by_attribute[@method]
37
+ # A nested-attributes permit names the key `<assoc>_attributes` (what Rails'
38
+ # strong-params and `fields_for` use) — resolve it back to the association,
39
+ # the same way `nested_attributes_type` chomps the suffix.
40
+ key = @method.to_s.chomp("_attributes")
41
+ model.try(:reflect_on_association, key) || reflections_by_attribute[key]
35
42
  end
36
43
 
37
44
  def abrogated_keys
@@ -42,11 +49,12 @@ module CafeCar
42
49
 
43
50
  def default_type
44
51
  case method
45
- when :controls then method
52
+ when :controls, :select then method
46
53
  end
47
54
  end
48
55
 
49
56
  def reflection_type = reflection&.macro
57
+ def enum_type = (:enum if enum?)
50
58
  def attribute_type = model.type_for_attribute(@method)&.type
51
59
  def digest_type
52
60
  key = @method.to_s.chomp("_confirmation")
@@ -85,7 +93,7 @@ module CafeCar
85
93
 
86
94
  def type
87
95
  return if @method.nil?
88
- @type ||= nested_attributes_type || reflection_type || attribute_type || digest_type ||
96
+ @type ||= nested_attributes_type || reflection_type || enum_type || attribute_type || digest_type ||
89
97
  attachment_type || default_type ||
90
98
  raise(NoMethodError.new "Can't find attribute :#{@method} on #{model_name}", @method)
91
99
  end
@@ -107,10 +115,12 @@ module CafeCar
107
115
  when :decimal then :text_field
108
116
  when :text, :json then :text_area
109
117
  when :integer then :number_field
118
+ when :boolean then :check_box
110
119
  when :date then :date_field
111
120
  when :datetime then :datetime_field
112
121
  when :password then :password_field
113
122
  when :nested then :fields_for
123
+ when :enum then :enum
114
124
  when :attachment then :file_field
115
125
  when :belongs_to, :has_many then :association
116
126
  when :has_one
@@ -4,15 +4,64 @@ class CafeCar::Filter::FieldInfo < CafeCar[:FieldInfo]
4
4
  rescue I18n::MissingTranslationData
5
5
  end
6
6
 
7
+ # A nested filter names a dot-path (`client.status`, `client.owner_id`) — the
8
+ # panel's control still posts the full path, but the TYPE that picks its control
9
+ # comes from the terminal attribute on the far model. `#terminal` is that far
10
+ # FieldInfo, reached by walking the leading association hops; the three data
11
+ # sources a control needs (type, enum values, reflection) delegate to it, so a
12
+ # nested belongs_to renders the same select as a top-level one, a nested enum
13
+ # the same enum select, etc. Labels/placeholders stay on the dotted path so they
14
+ # read disambiguated ("Client status"). `#method` keeps the dotted key, so every
15
+ # `_*_filter` partial names the control with the exact param the gate consumes.
16
+ def nested? = @method.to_s.include?(".")
17
+
18
+ def terminal
19
+ @terminal ||= begin
20
+ *hops, leaf = @method.to_s.split(".")
21
+ far = hops.reduce(@model) { |klass, hop| klass.reflect_on_association(hop).klass }
22
+ self.class.new(model: far, method: leaf)
23
+ end
24
+ end
25
+
26
+ def type = nested? ? terminal.type : super
27
+ def values = nested? ? terminal.values : super
28
+ def reflection = nested? ? terminal.reflection : super
29
+
30
+ # The submitted field name for the terminal control. A nested belongs_to posts
31
+ # the far foreign key under the full path (`client.owner` declared → the select
32
+ # posts `client.owner_id`); every other nested control posts the path itself.
33
+ def input_key
34
+ return super unless nested?
35
+ *hops, _leaf = @method.to_s.split(".")
36
+ type == :belongs_to ? [ *hops, terminal.reflection.foreign_key ].join(".") : @method.to_s
37
+ end
38
+
39
+ # An accepts_nested_attributes association is still just an association to
40
+ # filter by — the :nested type only matters to edit forms (fields_for).
41
+ def nested_attributes_type = nil
42
+
43
+ # Enum choices are the enum keys — the URL carries the readable key
44
+ # (`?status=archived`); QueryBuilder#parse_value passes enum keys through
45
+ # untouched and ActiveRecord casts them to the stored value.
46
+ def choices = values
47
+
48
+ # Types filtered by a min/max control pair (see _range_filter).
49
+ def range? = type.in?(%i[integer decimal float date datetime])
50
+
51
+ # Attributes the panel renders no control for: file attachments, rich text
52
+ # (keyword search already covers body text), polymorphic targets (no single
53
+ # collection to enumerate), and write-only password fields.
54
+ def unfilterable? = attachment? || rich_text? || polymorphic? || password?
55
+
7
56
  def input
8
57
  case type
9
- when :string then :text_field
10
- when :text then :text_field
11
- when :decimal then :text_field # :range_field
12
- when :integer then :text_field # :range_field
13
- when :date then :text_field
14
- when :datetime then :text_field
15
- when :password then :password_field
58
+ when :string, :text then :text_field
59
+ when :integer then :number_field
60
+ when :decimal, :float then :number_field
61
+ when :date, :datetime then :date_field
62
+ when :enum then :enum
63
+ when :boolean then :select
64
+ when :password then :password_field
16
65
  when :belongs_to, :has_many then :association
17
66
  when :has_one
18
67
  rich_text? ? :text_field : nil
@@ -1,21 +1,71 @@
1
1
  module CafeCar::Filter
2
2
  class FormBuilder < CafeCar[:FormBuilder]
3
+ # Rewrites a control's name to its bare dot-query key — `price` stays
4
+ # `price`, operators compose as `price.min` — instead of the edit form's
5
+ # `model[price]` nesting, so a submitted control IS a filter param
6
+ # (Controller::Filtering treats every non-control index param as a filter).
3
7
  def self.dotted_name(method)
4
8
  define_method method do |key, *args, **opts, &block|
5
9
  super(key, *args, name: field_name(key), **opts, &block)
6
10
  end
7
11
  end
8
12
 
9
- instance_methods.grep(/_(field|select)$/).each do |method|
10
- dotted_name method
13
+ instance_methods.grep(/_field$/).each { dotted_name _1 }
14
+
15
+ # The select family takes (options, html_options) hash pairs positionally,
16
+ # so the name rewrite rides in html_options (an explicit :name still wins).
17
+ def select(method, choices = nil, options = {}, html_options = {}, &block)
18
+ super(method, choices, options, { name: field_name(clean(method)) }.merge(html_options), &block)
19
+ end
20
+
21
+ def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
22
+ # A multi-select posts a set (`author_id[]`). We name every filter control
23
+ # explicitly, which defeats Rails' automatic `[]` suffix, so restore it here.
24
+ name = field_name(clean(method))
25
+ name += "[]" if html_options[:multiple]
26
+ super(method, collection, value_method, text_method, options,
27
+ { name: }.merge(html_options))
28
+ end
29
+
30
+ def model = object.model
31
+
32
+ def clean(method) = method.to_s.sub(/^\W+|\W+$/, "")
33
+ def field_name(*methods) = methods.join(".")
34
+
35
+ # Filter fields reflect through Filter::FieldInfo (the typed filter control
36
+ # mapping), keyed on the cleaned name so `name~` and `name` share one info.
37
+ def info(method)
38
+ @infos ||= {}
39
+ @infos[clean(method)] ||= const(:FieldInfo).new(model:, method: clean(method))
11
40
  end
12
41
 
13
- def clean(method) = method.to_s.sub(/^\W+|\W+$/, "")
14
- def info(method) = super(clean(method))
42
+ # The policy's permitted_filters — the panel's enumeration source — minus
43
+ # what the view already rendered and the types no control can express.
44
+ def remaining_attributes
45
+ policy.attributes.filterable.reject { info(_1).unfilterable? } - @fields.keys
46
+ end
47
+
48
+ def scopes = policy.permitted_scopes
49
+
50
+ # A filter is always optional — no required-* marker on its label.
51
+ def label(method, text = nil, **opts, &)
52
+ opts[:class] = [ ui.class(%i[field label]), *opts[:class] ]
53
+ super(method, *text, required: false, **opts, &)
54
+ end
55
+
56
+ # The typed control for one permitted filter: the `_<type>_filter` partial
57
+ # (host-overridable per type), `_range_filter` for the min/max family, then
58
+ # the generic `_filter` fallback.
59
+ def filter(method, **opts)
60
+ info = info(method)
61
+ partial = [ "#{info.type}_filter", ("range_filter" if info.range?), "filter" ]
62
+ .compact.find { @template.partial?(_1) }
63
+ @template.render(partial, f: self, info:, **opts)
64
+ end
15
65
 
16
- def field_name(*methods, multiple: false, index: @options[:index])
17
- # TODO: handle multiple/index
18
- [ "", *methods ].join(".")
66
+ # A checkbox that turns a permitted model scope on (`?published=true`).
67
+ def scope_toggle(scope)
68
+ @template.render("scope_filter", f: self, scope:)
19
69
  end
20
70
  end
21
71
  end