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
data/lib/cafe_car/policy.rb
CHANGED
|
@@ -13,30 +13,100 @@ module CafeCar::Policy
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def title_attribute
|
|
16
|
-
@title_attribute ||=
|
|
16
|
+
@title_attribute ||= attributes.displayable.first
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
# The bulk actions offered on this model's index table — the policy is the source
|
|
20
|
+
# of truth. Each name maps to a policy predicate (`name?`, authorized per record
|
|
21
|
+
# in Controller#batch) and a model bang method (`name!`, applied to each). Ships
|
|
22
|
+
# with `:destroy`; a host lists its own (e.g. `%i[publish destroy]`) by overriding
|
|
23
|
+
# this, with no registration anywhere else. Return `[]` to offer none.
|
|
24
|
+
def permitted_bulk_actions = %i[destroy]
|
|
25
|
+
|
|
26
|
+
# The custom actions offered on a single record — rendered on the show page's
|
|
27
|
+
# Actions card and on each index row. Same derivation as
|
|
28
|
+
# #permitted_bulk_actions: the `name?` predicate authorizes and the record's
|
|
29
|
+
# `name!` bang method runs (see Controller#member_action). Empty by default;
|
|
30
|
+
# a host lists its own (e.g. `%i[publish]`) with no registration anywhere else.
|
|
31
|
+
def permitted_member_actions = []
|
|
32
|
+
|
|
33
|
+
# The custom actions offered on the whole collection — rendered in the index
|
|
34
|
+
# toolbar. The `name?` predicate authorizes (asked of the model class, not a
|
|
35
|
+
# record) and `name!` runs on the policy scope, which ActiveRecord delegates
|
|
36
|
+
# to a class method (see Controller#collection_action). Empty by default.
|
|
37
|
+
def permitted_collection_actions = []
|
|
38
|
+
|
|
39
|
+
# The metrics the dashboard renders for this model by default — the same
|
|
40
|
+
# policy-is-source-of-truth pattern as #permitted_bulk_actions. Each name is a
|
|
41
|
+
# model scope (`:all` = the whole relation) whose row count becomes a tile; the
|
|
42
|
+
# `metrics` view helper reads this list. Empty by default (opt in per model).
|
|
43
|
+
def permitted_metrics = []
|
|
44
|
+
|
|
45
|
+
# The attributes (columns and associations) a user may filter an index by —
|
|
46
|
+
# the same policy-is-source-of-truth pattern: the filter UI enumerates this
|
|
47
|
+
# list, and Controller::Filtering drops URL filter keys that aren't on it.
|
|
48
|
+
# Defaults to `attributes.displayable` (Rails' parameter filter already strips
|
|
49
|
+
# password/token columns); a host overrides this to narrow or widen the list.
|
|
50
|
+
def permitted_filters = attributes.displayable
|
|
51
|
+
|
|
52
|
+
# The named model scopes invokable as URL filter params — `?published=true`
|
|
53
|
+
# calls the `published` scope. Empty by default (opt in per model): a scope
|
|
54
|
+
# key reaches `QueryBuilder#scope!`, which can invoke any public class method
|
|
55
|
+
# with a URL-supplied argument, so a host lists the safe ones explicitly.
|
|
56
|
+
def permitted_scopes = []
|
|
57
|
+
|
|
58
|
+
# Is `attribute` filterable? Compares the attribute's canonical form against the
|
|
59
|
+
# policy's #permitted_filters, canonicalized the same way — so a foreign key and
|
|
60
|
+
# its association match (`client_id` ≡ `:client`), and a nested dot-path
|
|
61
|
+
# (`client.status`) is honored only when its FULL path is permitted. The lone
|
|
62
|
+
# implicit exception is a permitted association's `.id` set-membership control
|
|
63
|
+
# (see #set_membership?), which needs no separate declaration.
|
|
64
|
+
def permitted_filter?(attribute)
|
|
65
|
+
path = canonical_filter(attribute.to_s)
|
|
66
|
+
permitted_filter_paths.include?(path) || set_membership?(path)
|
|
21
67
|
end
|
|
22
68
|
|
|
23
|
-
|
|
24
|
-
|
|
69
|
+
# The permitted filters as canonical dot-paths (foreign keys resolved to their
|
|
70
|
+
# associations) — the gate's whitelist, memoized. A nested filter is permitted
|
|
71
|
+
# iff its canonical path is a member, exactly like a top-level one.
|
|
72
|
+
def permitted_filter_paths
|
|
73
|
+
@permitted_filter_paths ||= permitted_filters.map { canonical_filter(_1.to_s) }
|
|
25
74
|
end
|
|
26
75
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
76
|
+
# `<assoc>.id` is the association-membership control (`?line_items.id[]=`): pick
|
|
77
|
+
# associated records by id. It's sanctioned whenever the association itself is a
|
|
78
|
+
# permitted filter, so the has_many `_has_many_filter` control needs no separate
|
|
79
|
+
# `.id` declaration — the same rule that let it through before nested paths.
|
|
80
|
+
def set_membership?(path)
|
|
81
|
+
parent, _, leaf = path.to_s.rpartition(".")
|
|
82
|
+
leaf == "id" && permitted_filter_paths.include?(parent)
|
|
32
83
|
end
|
|
33
84
|
|
|
34
|
-
|
|
35
|
-
|
|
85
|
+
# Canonical form of a (possibly dotted) filter key: each association hop keeps
|
|
86
|
+
# its association name and the terminal foreign key resolves to its association
|
|
87
|
+
# (`client.owner_id` → `client.owner`), so a declaration and the param a control
|
|
88
|
+
# posts compare equal regardless of which form the host wrote. Walks segment by
|
|
89
|
+
# segment through the associated models; a segment that isn't a real association
|
|
90
|
+
# (a bad hop, or the terminal attribute) passes through untouched, so an
|
|
91
|
+
# undeclared path simply fails the membership check — it never reaches SQL.
|
|
92
|
+
def canonical_filter(key, klass = model)
|
|
93
|
+
head, dot, rest = key.to_s.partition(".")
|
|
94
|
+
return (info_for(klass, head).reflection&.name || head).to_s if dot.empty?
|
|
95
|
+
|
|
96
|
+
ref = klass.reflect_on_association(head) or return key.to_s
|
|
97
|
+
"#{head}.#{canonical_filter(rest, ref.klass)}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def info_for(klass, method) = CafeCar[:FieldInfo].new(model: klass, method:)
|
|
101
|
+
|
|
102
|
+
def permitted_scope?(name) = permitted_scopes.include?(name.to_sym)
|
|
103
|
+
|
|
104
|
+
def logo_attribute
|
|
105
|
+
model.info.fields.listable.attachments.first&.method
|
|
36
106
|
end
|
|
37
107
|
|
|
38
|
-
def
|
|
39
|
-
permitted_fields.map
|
|
108
|
+
def permitted_fields
|
|
109
|
+
@permitted_fields ||= permitted_attribute_keys.map { info _1 }
|
|
40
110
|
end
|
|
41
111
|
|
|
42
112
|
def displayable_associations
|
|
@@ -68,7 +138,7 @@ module CafeCar::Policy
|
|
|
68
138
|
end
|
|
69
139
|
|
|
70
140
|
def displayable_attribute?(attribute)
|
|
71
|
-
|
|
141
|
+
attributes.displayable.include?(attribute.to_sym)
|
|
72
142
|
end
|
|
73
143
|
|
|
74
144
|
def association_for_attribute(attribute)
|
|
@@ -24,6 +24,14 @@ module CafeCar
|
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Word-form comparison operators for URL filters — `price.min=10` parses to
|
|
28
|
+
# `{ "price" => { "min" => "10" } }`, and each word maps to an Arel predicate.
|
|
29
|
+
# `min`/`max` read friendlier than `gte`/`lte` and are the documented spelling.
|
|
30
|
+
OPS = {
|
|
31
|
+
"eq" => "==", "gt" => ">", "lt" => "<",
|
|
32
|
+
"gte" => ">=", "lte" => "<=", "min" => ">=", "max" => "<="
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
27
35
|
attr_reader :scope
|
|
28
36
|
|
|
29
37
|
def initialize(scope)
|
|
@@ -67,6 +75,11 @@ module CafeCar
|
|
|
67
75
|
parse_range(key, value)
|
|
68
76
|
in Array | Op
|
|
69
77
|
value.map { parse_value(key, _1) }
|
|
78
|
+
# An enum key ("archived") passes through untouched — ActiveRecord casts it
|
|
79
|
+
# to the stored value itself; coercing by column type would `to_i` an
|
|
80
|
+
# integer-backed enum's key to 0, the wrong bucket.
|
|
81
|
+
in String if enum?(key)
|
|
82
|
+
value
|
|
70
83
|
in "true" then true
|
|
71
84
|
in "false" then false
|
|
72
85
|
in String
|
|
@@ -95,6 +108,8 @@ module CafeCar
|
|
|
95
108
|
|
|
96
109
|
def column(name) = @scope.columns_hash[name.to_s]
|
|
97
110
|
def reflection(name) = @scope.reflect_on_association(name)
|
|
111
|
+
def info(name) = CafeCar[:ModelInfo].find(@scope.model).field(chomp(name))
|
|
112
|
+
def enum?(name) = info(name).enum?
|
|
98
113
|
def association?(name) = reflection(name).present?
|
|
99
114
|
def attribute?(name) = column(name).present?
|
|
100
115
|
def scope?(name) = name.intern.in? @scope.local_methods
|
|
@@ -126,10 +141,17 @@ module CafeCar
|
|
|
126
141
|
@scope.where!(arel(key).matches_regexp(value.source, !value.casefold?))
|
|
127
142
|
in _, Op
|
|
128
143
|
@scope.where!(parse(key, value).arel(arel(key)))
|
|
144
|
+
in _, Hash if operators?(value)
|
|
145
|
+
value.each { |word, rhs| attribute!(key, Op.new(OPS[word.to_s], rhs)) }
|
|
129
146
|
else @scope.where!(key => parse(key, value))
|
|
130
147
|
end
|
|
131
148
|
end
|
|
132
149
|
|
|
150
|
+
# A filter value like `{ "min" => 10, "max" => 50 }` — every key a known
|
|
151
|
+
# comparison operator — desugars to `>= 10 AND <= 50` rather than an
|
|
152
|
+
# equality against the literal hash.
|
|
153
|
+
def operators?(value) = value.any? && value.keys.all? { OPS.key?(_1.to_s) }
|
|
154
|
+
|
|
133
155
|
def association!(name, value, ...)
|
|
134
156
|
update! do
|
|
135
157
|
case value
|
data/lib/cafe_car/routing.rb
CHANGED
|
@@ -6,12 +6,36 @@ module CafeCar
|
|
|
6
6
|
@concerns[:batchable] || begin
|
|
7
7
|
concern :batchable do
|
|
8
8
|
collection do
|
|
9
|
-
|
|
9
|
+
post :batch
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
# JSON typeahead feed for searchable association selects (Tom Select).
|
|
15
|
+
# `GET /<resources>/options?q=` returns policy-scoped [{value, text}] pairs,
|
|
16
|
+
# letting an association field reach records past `max_collection_options`.
|
|
17
|
+
@concerns[:searchable] || begin
|
|
18
|
+
concern :searchable do
|
|
19
|
+
collection do
|
|
20
|
+
get :options
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Policy-declared custom actions (Policy#permitted_member_actions /
|
|
26
|
+
# #permitted_collection_actions) route through one generic endpoint each —
|
|
27
|
+
# the action name is a URL param, so a host never enumerates them here.
|
|
28
|
+
# POST /<resources>/:id/actions/:member_action → Controller#member_action
|
|
29
|
+
# POST /<resources>/actions/:collection_action → Controller#collection_action
|
|
30
|
+
# The policy whitelists which names resolve; anything else is a 404.
|
|
31
|
+
@concerns[:actionable] || begin
|
|
32
|
+
concern :actionable do
|
|
33
|
+
member { post "actions/:member_action", action: :member_action, as: :member_action }
|
|
34
|
+
collection { post "actions/:collection_action", action: :collection_action, as: :collection_action }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
super(*, **, concerns: [ :batchable, :searchable, :actionable, *concerns ], &)
|
|
15
39
|
end
|
|
16
40
|
end
|
|
17
41
|
end
|
|
@@ -35,7 +35,7 @@ module CafeCar::Table
|
|
|
35
35
|
|
|
36
36
|
def timestamp_attribute = %w[updated_at created_at].find { has? _1 }
|
|
37
37
|
|
|
38
|
-
def remaining_attributes = policy.
|
|
38
|
+
def remaining_attributes = policy.attributes.listable - @shown_attributes.keys
|
|
39
39
|
|
|
40
40
|
def remaining(except: [])
|
|
41
41
|
capture do
|
|
@@ -21,6 +21,12 @@ module CafeCar::Table
|
|
|
21
21
|
|
|
22
22
|
def controls(*, **) = cell(:controls, :controls, *, label: nil, **)
|
|
23
23
|
|
|
24
|
+
def select(*, **)
|
|
25
|
+
return unless @template.bulk_actions?
|
|
26
|
+
check_all = @template.tag.input(type: :checkbox, data: { bulk_select_all: true }, "aria-label": "Select all")
|
|
27
|
+
cell(:select, :select, *, label: check_all, **)
|
|
28
|
+
end
|
|
29
|
+
|
|
24
30
|
def to_html = ui.Head(:sticky) { capture(self, &@block) }
|
|
25
31
|
end
|
|
26
32
|
end
|
|
@@ -32,6 +32,13 @@ module CafeCar::Table
|
|
|
32
32
|
ui.Cell(:shrink, :shy, :controls, *args) { present(@object).controls(*args, **options) }
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
def select(*args)
|
|
36
|
+
return unless @template.bulk_actions?
|
|
37
|
+
ui.Cell(:select, *args) do
|
|
38
|
+
@template.check_box_tag("ids[]", @object.id, false, id: nil, "aria-label": "Select this row")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
35
42
|
def to_html
|
|
36
43
|
ui.Row { safe_join [ capture(self, &@block), @template.turbo_stream_from(@object) ] }
|
|
37
44
|
end
|
data/lib/cafe_car/version.rb
CHANGED
data/lib/cafe_car.rb
CHANGED
|
@@ -3,12 +3,10 @@ require "cafe_car/core_ext"
|
|
|
3
3
|
require "cafe_car/version"
|
|
4
4
|
require "cafe_car/engine"
|
|
5
5
|
require "cafe_car/resolver"
|
|
6
|
-
require "cafe_car/auto_resolver"
|
|
7
6
|
require "cafe_car/proc_helpers"
|
|
8
7
|
|
|
9
8
|
module CafeCar
|
|
10
9
|
include Resolver
|
|
11
|
-
extend AutoResolver
|
|
12
10
|
extend ProcHelpers
|
|
13
11
|
|
|
14
12
|
class MissingAttributeError < StandardError
|
|
@@ -27,6 +25,38 @@ module CafeCar
|
|
|
27
25
|
# of exporting a large table; truncated exports signal `X-CafeCar-Truncated`.
|
|
28
26
|
mattr_accessor :csv_export_row_limit, default: 10_000
|
|
29
27
|
|
|
28
|
+
# Upper bound on an index page's `?per=`. Clamps oversized requests so a caller
|
|
29
|
+
# can't force the whole table into memory with `?per=1000000` (a DoS footgun);
|
|
30
|
+
# the effective per-page is silently capped, not rejected.
|
|
31
|
+
mattr_accessor :max_per_page, default: 200
|
|
32
|
+
|
|
33
|
+
# Upper bound on the number of `<option>`s an association select loads. Bounds
|
|
34
|
+
# the memory/latency cost of rendering a `belongs_to`/`has_many` field on every
|
|
35
|
+
# form and filter sidebar; without it a 10k-row association loads the whole
|
|
36
|
+
# table into memory each request. (A searchable/remote select is the fix for
|
|
37
|
+
# associations larger than this — a separate follow-up.)
|
|
38
|
+
mattr_accessor :max_collection_options, default: 100
|
|
39
|
+
|
|
40
|
+
# Bundled themes selectable via `CafeCar.theme`. Each lives at
|
|
41
|
+
# `app/assets/stylesheets/cafe_car/themes/<name>.css` as a set of CSS custom
|
|
42
|
+
# properties layered over `themes/defaults.css`, with its own dark-mode block.
|
|
43
|
+
THEMES = %i[warm cool cool2].freeze
|
|
44
|
+
|
|
45
|
+
# The active bundled theme, injected as a <link> into every CafeCar page's
|
|
46
|
+
# <head> (see `CafeCar::Helpers#theme_stylesheet_tag`). Defaults to `:warm` —
|
|
47
|
+
# the theme the engine has always shipped — so an unset host renders unchanged.
|
|
48
|
+
# Assigning a value outside THEMES raises rather than rendering unstyled.
|
|
49
|
+
mattr_reader :theme, default: :warm
|
|
50
|
+
|
|
51
|
+
def self.theme=(name)
|
|
52
|
+
name = name&.to_sym
|
|
53
|
+
unless THEMES.include?(name)
|
|
54
|
+
raise ArgumentError,
|
|
55
|
+
"unknown CafeCar theme #{name.inspect} (valid: #{THEMES.map(&:inspect).join(", ")})"
|
|
56
|
+
end
|
|
57
|
+
@@theme = name
|
|
58
|
+
end
|
|
59
|
+
|
|
30
60
|
# The host's user model, resolved lazily so the constant need not exist at
|
|
31
61
|
# boot. Used by CafeCar::Session for authentication.
|
|
32
62
|
def self.user_class = user_class_name.to_s.constantize
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Installs CafeCar's agent docs into the host app so coding agents reach for
|
|
3
|
+
CafeCar instead of hand-rolling admin UI.
|
|
4
|
+
|
|
5
|
+
Copies the CafeCar Agent Skill to .claude/skills/cafe_car/ (Claude Code)
|
|
6
|
+
and .agents/skills/cafe_car/ (Codex, Copilot, and other agents), and adds
|
|
7
|
+
a marker-delimited pointer block to AGENTS.md (created if missing).
|
|
8
|
+
|
|
9
|
+
Safe to re-run: the skill copies are overwritten and only the marked
|
|
10
|
+
block in AGENTS.md is replaced — your own content is never touched.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
bin/rails generate cafe_car:agents
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
class CafeCar::AgentsGenerator < Rails::Generators::Base
|
|
2
|
+
source_root File.expand_path("../../../..", __dir__)
|
|
3
|
+
|
|
4
|
+
BLOCK = <<~MD.freeze
|
|
5
|
+
<!-- cafe_car:start -->
|
|
6
|
+
This app uses the **cafe_car** gem. Before hand-rolling any admin / back-office / index / CRUD
|
|
7
|
+
UI, read `.claude/skills/cafe_car/SKILL.md` — point a controller at a model and the Pundit policy
|
|
8
|
+
drives the whole UI (filtering, sorting, turbo-streams, CSV) for free.
|
|
9
|
+
<!-- cafe_car:end -->
|
|
10
|
+
MD
|
|
11
|
+
|
|
12
|
+
MARKED_BLOCK = /^<!-- cafe_car:start -->.*?<!-- cafe_car:end -->/m
|
|
13
|
+
|
|
14
|
+
def copy_skill
|
|
15
|
+
directory "skills/cafe_car", ".claude/skills/cafe_car", force: true
|
|
16
|
+
directory "skills/cafe_car", ".agents/skills/cafe_car", force: true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def update_agents_md
|
|
20
|
+
if agents_md&.match?(MARKED_BLOCK)
|
|
21
|
+
gsub_file "AGENTS.md", MARKED_BLOCK, BLOCK.chomp
|
|
22
|
+
elsif agents_md
|
|
23
|
+
append_to_file "AGENTS.md", "\n#{BLOCK}"
|
|
24
|
+
else
|
|
25
|
+
create_file "AGENTS.md", BLOCK
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def post_install
|
|
30
|
+
say <<~MSG
|
|
31
|
+
|
|
32
|
+
CafeCar agent docs installed:
|
|
33
|
+
.claude/skills/cafe_car/ Claude Code skill
|
|
34
|
+
.agents/skills/cafe_car/ mirror for Codex, Copilot, and other agents
|
|
35
|
+
AGENTS.md pointer block (re-runs replace only the marked block)
|
|
36
|
+
|
|
37
|
+
Optional, for your team:
|
|
38
|
+
npx skills add craft-concept/cafe_car # install the skill in any skills-aware tool
|
|
39
|
+
https://gitmcp.io/craft-concept/cafe_car # live CafeCar docs over MCP, zero setup
|
|
40
|
+
MSG
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def agents_md
|
|
46
|
+
path = File.join(destination_root, "AGENTS.md")
|
|
47
|
+
File.read(path) if File.exist?(path)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -25,7 +25,18 @@ class CafeCar::ResourceGenerator < Rails::Generators::NamedBase
|
|
|
25
25
|
# generator's `field:type:index` form. Forwarding them lets the policy list
|
|
26
26
|
# real permitted attributes instead of falling back to model introspection,
|
|
27
27
|
# which can't see a model that isn't a loaded constant yet mid-run.
|
|
28
|
-
|
|
28
|
+
#
|
|
29
|
+
# A `:references`/`belongs_to` field must be permitted by its foreign key
|
|
30
|
+
# (`invoice_id`), not the bare association (`invoice`) — that's the column
|
|
31
|
+
# strong-params actually receives. Polymorphic refs need `_id` + `_type`
|
|
32
|
+
# (mirrors notes_generator's hardcoded `notable_id notable_type`).
|
|
33
|
+
def field_names
|
|
34
|
+
attributes.flat_map do |attr|
|
|
35
|
+
next attr.name unless attr.reference?
|
|
36
|
+
|
|
37
|
+
[ "#{attr.name}_id", ("#{attr.name}_type" if attr.polymorphic?) ].compact
|
|
38
|
+
end
|
|
39
|
+
end
|
|
29
40
|
|
|
30
41
|
def assign_controller_names!(...)
|
|
31
42
|
if options[:model_name].blank?
|
data/lib/tasks/demo.rake
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
namespace :demo do
|
|
2
|
+
desc "Effect-level smoke check against the live demo (DEMO_URL to override the host)"
|
|
3
|
+
task :smoke do
|
|
4
|
+
script = File.expand_path("../../bin/demo-smoke", __dir__)
|
|
5
|
+
# Deliberately standalone + on-demand — NOT part of the `default` (CI) task:
|
|
6
|
+
# coupling repo CI to the external demo's uptime would make CI flaky.
|
|
7
|
+
exit(1) unless system(RbConfig.ruby, script)
|
|
8
|
+
end
|
|
9
|
+
end
|
data/llms.txt
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# CafeCar
|
|
2
|
+
|
|
3
|
+
> CafeCar is a composable view extension for Rails. Point a controller at a model and the Pundit policy drives complete index, show, new, and edit pages — keyword search, URL-based filtering and sorting, pagination, CSV export, bulk actions, and Turbo-morph live updates included. Every default can be overridden with ordinary Rails views and partials; there is no config DSL.
|
|
4
|
+
|
|
5
|
+
Key conventions: the policy is the source of truth (what's visible, editable, and permitted); customization goes policy → presenter → partial override → controller, cheapest first; UI copy lives in locales; styles live in component CSS.
|
|
6
|
+
|
|
7
|
+
## Docs
|
|
8
|
+
|
|
9
|
+
- [README](https://github.com/craft-concept/cafe_car/blob/main/README.md): installation, getting started, and the full feature reference
|
|
10
|
+
- [Agent Skill](https://github.com/craft-concept/cafe_car/blob/main/skills/cafe_car/SKILL.md): the mental model and customization ladder, written for coding agents
|
|
11
|
+
- [Controllers](https://github.com/craft-concept/cafe_car/blob/main/skills/cafe_car/references/controllers.md): the `cafe_car` macro, scope, callbacks, custom actions
|
|
12
|
+
- [Policies](https://github.com/craft-concept/cafe_car/blob/main/skills/cafe_car/references/policies.md): Pundit policies as the source of truth — permissions, scopes, permitted attributes
|
|
13
|
+
- [Presenters](https://github.com/craft-concept/cafe_car/blob/main/skills/cafe_car/references/presenters.md): per-attribute rendering with `show ... as:`
|
|
14
|
+
- [Views](https://github.com/craft-concept/cafe_car/blob/main/skills/cafe_car/references/views.md): the partial-override ladder, per-resource and app-wide
|
|
15
|
+
- [Forms](https://github.com/craft-concept/cafe_car/blob/main/skills/cafe_car/references/forms.md): automatic forms from permitted attributes
|
|
16
|
+
- [Filtering](https://github.com/craft-concept/cafe_car/blob/main/skills/cafe_car/references/filtering.md): URL query params for search, ranges, comparisons, sorting
|
|
17
|
+
- [Components](https://github.com/craft-concept/cafe_car/blob/main/skills/cafe_car/references/components.md): the UI component set and component-scoped styling
|
|
18
|
+
- [Turbo](https://github.com/craft-concept/cafe_car/blob/main/skills/cafe_car/references/turbo.md): Turbo Stream responses and live morph updates
|
|
19
|
+
- [Locales](https://github.com/craft-concept/cafe_car/blob/main/skills/cafe_car/references/locales.md): all UI copy via i18n
|
|
20
|
+
- [Navigation](https://github.com/craft-concept/cafe_car/blob/main/skills/cafe_car/references/navigation.md): menus and layout
|
|
21
|
+
- [CHANGELOG](https://github.com/craft-concept/cafe_car/blob/main/CHANGELOG.md): what changed in each release
|
|
22
|
+
|
|
23
|
+
## Optional
|
|
24
|
+
|
|
25
|
+
- [Live demo](https://cafe-car-demo-production.up.railway.app): a clickable CafeCar app, no signup
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cafe_car
|
|
3
|
+
description: >-
|
|
4
|
+
Use when building admin, back-office, index, or CRUD UI in a Rails app that has the
|
|
5
|
+
cafe_car gem: point a controller at a model (the `cafe_car` macro) and the Pundit
|
|
6
|
+
policy drives complete index/show/new/edit pages — filtering, sorting, keyword search,
|
|
7
|
+
pagination, CSV export, bulk actions, and Turbo-morph live updates included. Do not
|
|
8
|
+
hand-roll admin controllers or views in these apps; extend CafeCar's defaults.
|
|
9
|
+
when_to_use: >-
|
|
10
|
+
Any task touching list/detail/form UI for an ActiveRecord model in an app with
|
|
11
|
+
cafe_car in the Gemfile: adding an admin section, changing visible columns or fields,
|
|
12
|
+
restricting access, filtering or sorting an index, adding buttons to cards or rows,
|
|
13
|
+
bulk actions, dashboards, theming, or live-updating records across clients.
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# CafeCar
|
|
17
|
+
|
|
18
|
+
CafeCar is a composable view extension for Rails. It renders index, show, new, and
|
|
19
|
+
edit straight from the model at runtime — you delete view files instead of writing
|
|
20
|
+
them. One controller line gives you the whole CRUD surface; you then override only
|
|
21
|
+
the pieces that need to differ.
|
|
22
|
+
|
|
23
|
+
**Never hand-roll an admin page in a CafeCar app.** A hand-written index loses
|
|
24
|
+
Turbo-morph updates, the standard query params, policy-driven columns, association
|
|
25
|
+
links, bulk actions, and CSV export — all of which the default views carry for free.
|
|
26
|
+
|
|
27
|
+
## The mental model: the policy declares, the UI renders
|
|
28
|
+
|
|
29
|
+
The Pundit policy is the source of truth. It declares who may do what
|
|
30
|
+
(`index?`, `update?`, …), which rows are visible (`Scope#resolve`), which fields are
|
|
31
|
+
editable (`permitted_attributes`), which bulk actions exist
|
|
32
|
+
(`permitted_bulk_actions`), and which dashboard metrics show (`permitted_metrics`).
|
|
33
|
+
The default views render exactly what the policy declares.
|
|
34
|
+
|
|
35
|
+
So the customization ladder, cheapest first:
|
|
36
|
+
|
|
37
|
+
1. **Policy** — change what's permitted/visible. Most "change the UI" tasks end here.
|
|
38
|
+
2. **Presenter** — change how a value renders (`show :total, as: :currency`).
|
|
39
|
+
3. **Partial override** — drop a same-named view file to replace one piece, for one
|
|
40
|
+
resource or app-wide.
|
|
41
|
+
4. **Controller** — override `scope`, add callbacks, or replace an action.
|
|
42
|
+
|
|
43
|
+
Configuration is views and partials, not config DSLs. Copy lives in locales, not
|
|
44
|
+
templates. Styles live in component CSS, not global stylesheets.
|
|
45
|
+
|
|
46
|
+
## Point a controller at a model
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# config/routes.rb
|
|
50
|
+
namespace :admin do
|
|
51
|
+
resources :products # also auto-adds POST batch + GET options collection routes
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# app/controllers/admin/products_controller.rb
|
|
55
|
+
module Admin
|
|
56
|
+
class ProductsController < ApplicationController
|
|
57
|
+
cafe_car
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# app/policies/product_policy.rb
|
|
62
|
+
class ProductPolicy < ApplicationPolicy
|
|
63
|
+
def index? = user.present?
|
|
64
|
+
def show? = index?
|
|
65
|
+
def create? = user.admin?
|
|
66
|
+
def update? = user.admin?
|
|
67
|
+
def destroy? = update?
|
|
68
|
+
|
|
69
|
+
def permitted_attributes = %i[name price description category_id]
|
|
70
|
+
|
|
71
|
+
class Scope < Scope
|
|
72
|
+
def resolve = scope.all
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
That's the whole feature. `/admin/products` now serves index (table/grid/chart
|
|
78
|
+
views), show, new, edit, JSON, and CSV. Generators scaffold these files:
|
|
79
|
+
`bin/rails g cafe_car:resource admin/products name:string price:decimal` (model +
|
|
80
|
+
migration + route + controller + policy), or `cafe_car:controller` /
|
|
81
|
+
`cafe_car:policy` individually. First-time setup in a bare app:
|
|
82
|
+
`bin/rails g cafe_car:install`.
|
|
83
|
+
|
|
84
|
+
Every ActiveRecord model also gets `.query(params)`, `.sorted(*keys)`, and `.info`
|
|
85
|
+
automatically — no opt-in, no include.
|
|
86
|
+
|
|
87
|
+
## Recipes
|
|
88
|
+
|
|
89
|
+
| How do I… | See |
|
|
90
|
+
|---|---|
|
|
91
|
+
| Add an admin section for a model | above; [controllers](references/controllers.md) |
|
|
92
|
+
| Change which columns/fields show or are editable | [policies](references/policies.md) |
|
|
93
|
+
| Restrict which rows a user sees | [policies](references/policies.md) — `Scope#resolve` |
|
|
94
|
+
| Narrow one controller's collection (`def scope = super.published`) | [controllers](references/controllers.md) |
|
|
95
|
+
| Add a bulk action (Publish button on the index) | [policies](references/policies.md) — bulk actions |
|
|
96
|
+
| Filter/sort/search an index via URL params | [filtering](references/filtering.md) |
|
|
97
|
+
| Add buttons or fields to grid cards / table rows | [views](references/views.md) |
|
|
98
|
+
| Override one view for one resource vs app-wide | [views](references/views.md) |
|
|
99
|
+
| Change how a value renders (currency, links, previews) | [presenters](references/presenters.md) |
|
|
100
|
+
| Customize a form field or input type | [forms](references/forms.md) |
|
|
101
|
+
| Add a sidebar link | add an `index` route — [navigation](references/navigation.md) |
|
|
102
|
+
| Add a dashboard with metric tiles and charts | [navigation](references/navigation.md) — dashboard |
|
|
103
|
+
| Re-theme, style a component, add a component | [components](references/components.md) |
|
|
104
|
+
| Make edits sync live across clients | [turbo](references/turbo.md) — `broadcasts_refreshes` |
|
|
105
|
+
| Change labels, hints, flashes, button styles | [locales](references/locales.md) |
|
|
106
|
+
| Link to a related object | [presenters](references/presenters.md) — `href_for`, `link` |
|
|
107
|
+
|
|
108
|
+
## Reference pages
|
|
109
|
+
|
|
110
|
+
- [controllers.md](references/controllers.md) — the `cafe_car` macro, the scope
|
|
111
|
+
pipeline, callbacks, batch/options endpoints
|
|
112
|
+
- [policies.md](references/policies.md) — the source of truth: predicates, scopes,
|
|
113
|
+
attributes, bulk actions, metrics
|
|
114
|
+
- [presenters.md](references/presenters.md) — how values render; `show` macros
|
|
115
|
+
- [forms.md](references/forms.md) — the form builder, type inference, nested records
|
|
116
|
+
- [filtering.md](references/filtering.md) — the dot-query URL grammar, sort, search,
|
|
117
|
+
CSV, chart view
|
|
118
|
+
- [views.md](references/views.md) — the partial override system (the crux)
|
|
119
|
+
- [components.md](references/components.md) — the UI component system, CSS, theming
|
|
120
|
+
- [navigation.md](references/navigation.md) — route-driven sidebar; the dashboard
|
|
121
|
+
- [turbo.md](references/turbo.md) — Turbo-morph refreshes and cross-client sync
|
|
122
|
+
- [locales.md](references/locales.md) — all UI copy and where its keys live
|
|
123
|
+
|
|
124
|
+
## Ground truth
|
|
125
|
+
|
|
126
|
+
These pages teach conventions, not a frozen API. When in doubt, read the shipped
|
|
127
|
+
defaults — they are the best examples of idiomatic CafeCar and always current:
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
cd "$(bundle show cafe_car)"
|
|
131
|
+
ls app/views/application/ # overridable shared partials
|
|
132
|
+
ls app/views/cafe_car/application/ # action templates + turbo_stream responses
|
|
133
|
+
cat lib/cafe_car/controller.rb # the macro and the scope pipeline
|
|
134
|
+
cat lib/cafe_car/policy.rb # everything a policy can declare
|
|
135
|
+
```
|