cafe_car 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/.claude-plugin/marketplace.json +23 -0
  3. data/README.md +417 -29
  4. data/Rakefile +2 -2
  5. data/app/assets/stylesheets/cafe_car/chart.css +41 -0
  6. data/app/assets/stylesheets/cafe_car/dashboard.css +43 -0
  7. data/app/assets/stylesheets/cafe_car/filters.css +50 -0
  8. data/app/assets/stylesheets/cafe_car/themes/cool2.css +2 -0
  9. data/app/assets/stylesheets/cafe_car/themes/warm.css +30 -0
  10. data/app/assets/stylesheets/cafe_car/tooltips.css +1 -1
  11. data/app/assets/stylesheets/cafe_car.css +14 -4
  12. data/app/assets/stylesheets/tom-select.css +95 -0
  13. data/app/assets/stylesheets/ui/Button.css +7 -5
  14. data/app/assets/stylesheets/ui/Input.css +92 -1
  15. data/app/assets/stylesheets/ui/Page.css +7 -0
  16. data/app/assets/stylesheets/ui/Table.css +25 -0
  17. data/app/controllers/cafe_car/dashboards_controller.rb +29 -0
  18. data/app/javascript/cafe_car.js +68 -0
  19. data/app/javascript/tom-select.complete.min.js +444 -0
  20. data/app/policies/cafe_car/application_policy.rb +1 -1
  21. data/app/presenters/cafe_car/active_storage/attachment_presenter.rb +4 -1
  22. data/app/presenters/cafe_car/presenter.rb +2 -2
  23. data/app/views/application/_actions.html.haml +9 -0
  24. data/app/views/application/_active_filters.html.haml +15 -0
  25. data/app/views/application/_belongs_to_filter.html.haml +6 -0
  26. data/app/views/application/_boolean_filter.html.haml +4 -0
  27. data/app/views/application/_bulk_actions.html.haml +9 -0
  28. data/app/views/application/_chart.html.haml +18 -0
  29. data/app/views/application/_controls.html.haml +8 -0
  30. data/app/views/application/_enum_filter.html.haml +5 -0
  31. data/app/views/application/_filter.html.haml +4 -0
  32. data/app/views/application/_filters.html.haml +13 -4
  33. data/app/views/application/_has_many_filter.html.haml +7 -0
  34. data/app/views/application/_head.html.haml +1 -0
  35. data/app/views/application/_index.html.haml +3 -1
  36. data/app/views/application/_index_actions.html.haml +12 -3
  37. data/app/views/application/_navigation.html.haml +5 -0
  38. data/app/views/application/_nested_field.html.haml +1 -1
  39. data/app/views/application/_range_filter.html.haml +8 -0
  40. data/app/views/application/_scope_filter.html.haml +5 -0
  41. data/app/views/application/_string_filter.html.haml +4 -0
  42. data/app/views/application/_table.html.haml +8 -6
  43. data/app/views/application/_text_filter.html.haml +1 -0
  44. data/app/views/cafe_car/application/index.html.haml +5 -2
  45. data/app/views/cafe_car/dashboard/_chart.html.haml +11 -0
  46. data/app/views/cafe_car/dashboard/_metric.html.haml +4 -0
  47. data/config/brakeman.ignore +0 -48
  48. data/config/importmap.rb +1 -0
  49. data/config/locales/en.yml +60 -0
  50. data/config/routes.rb +15 -2
  51. data/lib/cafe_car/attributes.rb +40 -12
  52. data/lib/cafe_car/chart_builder.rb +217 -0
  53. data/lib/cafe_car/controller/filtering.rb +108 -6
  54. data/lib/cafe_car/controller.rb +220 -11
  55. data/lib/cafe_car/engine.rb +2 -2
  56. data/lib/cafe_car/field_info.rb +14 -4
  57. data/lib/cafe_car/filter/field_info.rb +56 -7
  58. data/lib/cafe_car/filter/form_builder.rb +57 -7
  59. data/lib/cafe_car/filter_builder.rb +14 -1
  60. data/lib/cafe_car/form_builder.rb +61 -6
  61. data/lib/cafe_car/helpers.rb +196 -0
  62. data/lib/cafe_car/inputs/association_input.rb +11 -0
  63. data/lib/cafe_car/inputs/base_input.rb +56 -7
  64. data/lib/cafe_car/inputs/boolean_input.rb +9 -0
  65. data/lib/cafe_car/inputs/date_input.rb +8 -0
  66. data/lib/cafe_car/inputs/datetime_input.rb +8 -0
  67. data/lib/cafe_car/inputs/file_input.rb +11 -0
  68. data/lib/cafe_car/inputs/nested_input.rb +10 -0
  69. data/lib/cafe_car/inputs/number_input.rb +9 -0
  70. data/lib/cafe_car/inputs/password_input.rb +4 -2
  71. data/lib/cafe_car/inputs/rich_text_input.rb +9 -0
  72. data/lib/cafe_car/inputs/select_input.rb +10 -0
  73. data/lib/cafe_car/inputs/string_input.rb +3 -1
  74. data/lib/cafe_car/inputs/text_area_input.rb +9 -0
  75. data/lib/cafe_car/link_builder.rb +11 -0
  76. data/lib/cafe_car/model.rb +37 -8
  77. data/lib/cafe_car/navigation.rb +10 -0
  78. data/lib/cafe_car/policy.rb +85 -15
  79. data/lib/cafe_car/query_builder.rb +22 -0
  80. data/lib/cafe_car/routing.rb +26 -2
  81. data/lib/cafe_car/table/builder.rb +1 -1
  82. data/lib/cafe_car/table/foot_builder.rb +1 -0
  83. data/lib/cafe_car/table/head_builder.rb +6 -0
  84. data/lib/cafe_car/table/row_builder.rb +7 -0
  85. data/lib/cafe_car/version.rb +1 -1
  86. data/lib/cafe_car.rb +32 -2
  87. data/lib/generators/cafe_car/agents/USAGE +13 -0
  88. data/lib/generators/cafe_car/agents/agents_generator.rb +49 -0
  89. data/lib/generators/cafe_car/resource/resource_generator.rb +12 -1
  90. data/lib/tasks/demo.rake +9 -0
  91. data/llms.txt +25 -0
  92. data/skills/cafe_car/SKILL.md +135 -0
  93. data/skills/cafe_car/references/components.md +93 -0
  94. data/skills/cafe_car/references/controllers.md +106 -0
  95. data/skills/cafe_car/references/filtering.md +102 -0
  96. data/skills/cafe_car/references/forms.md +84 -0
  97. data/skills/cafe_car/references/locales.md +74 -0
  98. data/skills/cafe_car/references/navigation.md +67 -0
  99. data/skills/cafe_car/references/policies.md +112 -0
  100. data/skills/cafe_car/references/presenters.md +76 -0
  101. data/skills/cafe_car/references/turbo.md +53 -0
  102. data/skills/cafe_car/references/views.md +96 -0
  103. metadata +59 -16
  104. data/app/assets/stylesheets/cafe_car/themes/warm-dark.css +0 -29
  105. data/app/views/application/_search.html.haml +0 -6
  106. data/lib/cafe_car/auto_resolver.rb +0 -49
  107. data/lib/cafe_car/inputs/association_builder.rb +0 -6
  108. data/lib/cafe_car/inputs/belongs_to_builder.rb +0 -6
  109. data/lib/tasks/holdco_tasks.rake +0 -532
  110. data/lib/tasks/templates/tasks_header.md +0 -37
@@ -13,30 +13,100 @@ module CafeCar::Policy
13
13
  end
14
14
 
15
15
  def title_attribute
16
- @title_attribute ||= displayable_attributes.first
16
+ @title_attribute ||= attributes.displayable.first
17
17
  end
18
18
 
19
- def logo_attribute
20
- model.info.fields.listable.attachments.first&.method
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
- def listable_attributes
24
- model.info.fields.listable.map(&:method)
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
- def displayable_attributes
28
- permitted_attribute_keys
29
- .union(model.columns.map(&:name).map(&:to_sym))
30
- .map { association_for_attribute(_1) || _1 }
31
- .reject { filtered_attribute? _1 } - %i[id]
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
- def permitted_fields
35
- @permitted_fields ||= permitted_attribute_keys.map { info _1 }
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 editable_attributes
39
- permitted_fields.map(&:input_key) - permitted_fields.flat_map(&:abrogated_keys)
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
- displayable_attributes.include?(attribute.to_sym)
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
@@ -6,12 +6,36 @@ module CafeCar
6
6
  @concerns[:batchable] || begin
7
7
  concern :batchable do
8
8
  collection do
9
- patch :update_all
9
+ post :batch
10
10
  end
11
11
  end
12
12
  end
13
13
 
14
- super(*, **, concerns: [ :batchable, *concerns ], &)
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.listable_attributes - @shown_attributes.keys
38
+ def remaining_attributes = policy.attributes.listable - @shown_attributes.keys
39
39
 
40
40
  def remaining(except: [])
41
41
  capture do
@@ -8,6 +8,7 @@ module CafeCar::Table
8
8
  def cell! = (@count += 1; nil)
9
9
  def cell(...) = (super; cell!)
10
10
  def controls(...) = cell!
11
+ def select(...) = (cell! if @template.bulk_actions?)
11
12
 
12
13
  def to_html = ""
13
14
  end
@@ -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
@@ -1,3 +1,3 @@
1
1
  module CafeCar
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.0"
3
3
  end
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
- def field_names = attributes.map { _1.to_s.split(":").first }
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?
@@ -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
+ ```