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.
- checksums.yaml +4 -4
- data/.claude-plugin/marketplace.json +23 -0
- data/README.md +417 -29
- data/Rakefile +2 -2
- data/app/assets/stylesheets/cafe_car/chart.css +41 -0
- data/app/assets/stylesheets/cafe_car/dashboard.css +43 -0
- data/app/assets/stylesheets/cafe_car/filters.css +50 -0
- data/app/assets/stylesheets/cafe_car/themes/cool2.css +2 -0
- data/app/assets/stylesheets/cafe_car/themes/warm.css +30 -0
- data/app/assets/stylesheets/cafe_car/tooltips.css +1 -1
- data/app/assets/stylesheets/cafe_car.css +14 -4
- data/app/assets/stylesheets/tom-select.css +95 -0
- data/app/assets/stylesheets/ui/Button.css +7 -5
- data/app/assets/stylesheets/ui/Input.css +92 -1
- data/app/assets/stylesheets/ui/Page.css +7 -0
- data/app/assets/stylesheets/ui/Table.css +25 -0
- data/app/controllers/cafe_car/dashboards_controller.rb +29 -0
- data/app/javascript/cafe_car.js +68 -0
- data/app/javascript/tom-select.complete.min.js +444 -0
- data/app/policies/cafe_car/application_policy.rb +1 -1
- data/app/presenters/cafe_car/active_storage/attachment_presenter.rb +4 -1
- data/app/presenters/cafe_car/presenter.rb +2 -2
- data/app/views/application/_actions.html.haml +9 -0
- data/app/views/application/_active_filters.html.haml +15 -0
- data/app/views/application/_belongs_to_filter.html.haml +6 -0
- data/app/views/application/_boolean_filter.html.haml +4 -0
- data/app/views/application/_bulk_actions.html.haml +9 -0
- data/app/views/application/_chart.html.haml +18 -0
- data/app/views/application/_controls.html.haml +8 -0
- data/app/views/application/_enum_filter.html.haml +5 -0
- data/app/views/application/_filter.html.haml +4 -0
- data/app/views/application/_filters.html.haml +13 -4
- data/app/views/application/_has_many_filter.html.haml +7 -0
- data/app/views/application/_head.html.haml +1 -0
- data/app/views/application/_index.html.haml +3 -1
- data/app/views/application/_index_actions.html.haml +12 -3
- data/app/views/application/_navigation.html.haml +5 -0
- data/app/views/application/_nested_field.html.haml +1 -1
- data/app/views/application/_range_filter.html.haml +8 -0
- data/app/views/application/_scope_filter.html.haml +5 -0
- data/app/views/application/_string_filter.html.haml +4 -0
- data/app/views/application/_table.html.haml +8 -6
- data/app/views/application/_text_filter.html.haml +1 -0
- data/app/views/cafe_car/application/index.html.haml +5 -2
- data/app/views/cafe_car/dashboard/_chart.html.haml +11 -0
- data/app/views/cafe_car/dashboard/_metric.html.haml +4 -0
- data/config/brakeman.ignore +0 -48
- data/config/importmap.rb +1 -0
- data/config/locales/en.yml +60 -0
- data/config/routes.rb +15 -2
- data/lib/cafe_car/attributes.rb +40 -12
- data/lib/cafe_car/chart_builder.rb +217 -0
- data/lib/cafe_car/controller/filtering.rb +108 -6
- data/lib/cafe_car/controller.rb +220 -11
- data/lib/cafe_car/engine.rb +2 -2
- data/lib/cafe_car/field_info.rb +14 -4
- data/lib/cafe_car/filter/field_info.rb +56 -7
- data/lib/cafe_car/filter/form_builder.rb +57 -7
- data/lib/cafe_car/filter_builder.rb +14 -1
- data/lib/cafe_car/form_builder.rb +61 -6
- data/lib/cafe_car/helpers.rb +196 -0
- data/lib/cafe_car/inputs/association_input.rb +11 -0
- data/lib/cafe_car/inputs/base_input.rb +56 -7
- data/lib/cafe_car/inputs/boolean_input.rb +9 -0
- data/lib/cafe_car/inputs/date_input.rb +8 -0
- data/lib/cafe_car/inputs/datetime_input.rb +8 -0
- data/lib/cafe_car/inputs/file_input.rb +11 -0
- data/lib/cafe_car/inputs/nested_input.rb +10 -0
- data/lib/cafe_car/inputs/number_input.rb +9 -0
- data/lib/cafe_car/inputs/password_input.rb +4 -2
- data/lib/cafe_car/inputs/rich_text_input.rb +9 -0
- data/lib/cafe_car/inputs/select_input.rb +10 -0
- data/lib/cafe_car/inputs/string_input.rb +3 -1
- data/lib/cafe_car/inputs/text_area_input.rb +9 -0
- data/lib/cafe_car/link_builder.rb +11 -0
- data/lib/cafe_car/model.rb +37 -8
- data/lib/cafe_car/navigation.rb +10 -0
- data/lib/cafe_car/policy.rb +85 -15
- data/lib/cafe_car/query_builder.rb +22 -0
- data/lib/cafe_car/routing.rb +26 -2
- data/lib/cafe_car/table/builder.rb +1 -1
- data/lib/cafe_car/table/foot_builder.rb +1 -0
- data/lib/cafe_car/table/head_builder.rb +6 -0
- data/lib/cafe_car/table/row_builder.rb +7 -0
- data/lib/cafe_car/version.rb +1 -1
- data/lib/cafe_car.rb +32 -2
- data/lib/generators/cafe_car/agents/USAGE +13 -0
- data/lib/generators/cafe_car/agents/agents_generator.rb +49 -0
- data/lib/generators/cafe_car/resource/resource_generator.rb +12 -1
- data/lib/tasks/demo.rake +9 -0
- data/llms.txt +25 -0
- data/skills/cafe_car/SKILL.md +135 -0
- data/skills/cafe_car/references/components.md +93 -0
- data/skills/cafe_car/references/controllers.md +106 -0
- data/skills/cafe_car/references/filtering.md +102 -0
- data/skills/cafe_car/references/forms.md +84 -0
- data/skills/cafe_car/references/locales.md +74 -0
- data/skills/cafe_car/references/navigation.md +67 -0
- data/skills/cafe_car/references/policies.md +112 -0
- data/skills/cafe_car/references/presenters.md +76 -0
- data/skills/cafe_car/references/turbo.md +53 -0
- data/skills/cafe_car/references/views.md +96 -0
- metadata +59 -16
- data/app/assets/stylesheets/cafe_car/themes/warm-dark.css +0 -29
- data/app/views/application/_search.html.haml +0 -6
- data/lib/cafe_car/auto_resolver.rb +0 -49
- data/lib/cafe_car/inputs/association_builder.rb +0 -6
- data/lib/cafe_car/inputs/belongs_to_builder.rb +0 -6
- data/lib/tasks/holdco_tasks.rake +0 -532
- 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, :
|
|
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([
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
data/lib/cafe_car/controller.rb
CHANGED
|
@@ -12,8 +12,14 @@ module CafeCar
|
|
|
12
12
|
define_method(:model) { @model ||= model }
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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(*
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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).
|
|
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
|
data/lib/cafe_car/engine.rb
CHANGED
|
@@ -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] |
|
|
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).
|
|
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
|
|
data/lib/cafe_car/field_info.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
10
|
-
when :
|
|
11
|
-
when :decimal
|
|
12
|
-
when :
|
|
13
|
-
when :
|
|
14
|
-
when :
|
|
15
|
-
when :password
|
|
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(/
|
|
10
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|