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
|
@@ -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
|
|
23
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
152
|
+
def remaining_attributes = policy.attributes.editable - @fields.keys
|
|
98
153
|
|
|
99
154
|
def remaining_fields(**, &block)
|
|
100
155
|
block ||= proc { field(_1, **) }
|
data/lib/cafe_car/helpers.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
12
|
-
def type = :text
|
|
49
|
+
def info = form.info(method)
|
|
13
50
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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,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
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
module CafeCar
|
|
2
2
|
module Inputs
|
|
3
|
-
|
|
4
|
-
|
|
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,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
|
|
@@ -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), **, &)
|
data/lib/cafe_car/model.rb
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
reorder(*
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
data/lib/cafe_car/navigation.rb
CHANGED
|
@@ -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
|
|