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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8782de0874fe886849232cebef1d036e0cc43680e758a5bfdc2a0b027e26b06c
|
|
4
|
+
data.tar.gz: 346735b4f4568e3c28a751e8dde44d5e85fa0b5d907c2ce95e0e004ff4277e1e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 84003696580e71078eced5706bf2a4d33858de2aee2a2b8ac3c2764838f53226d6fa5c1b6015a9bec4ec5f8049ef7cbd3188db462ad6500f29e79c6ff5b9778a
|
|
7
|
+
data.tar.gz: 4ec99470b492701f531e2977d0fd88cc2f33fa3b4b35327b25a3b5b11f233b75fb91a5b2fdad3ff72a5398975c130cc8b6ffca4db5e159ebc6cfdbcd8c8c036b
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://www.schemastore.org/claude-code-marketplace.json",
|
|
3
|
+
"name": "cafe-car",
|
|
4
|
+
"description": "CafeCar — a composable view extension for Rails",
|
|
5
|
+
"owner": {
|
|
6
|
+
"name": "Jeff Peterson",
|
|
7
|
+
"email": "jeff@yak.sh"
|
|
8
|
+
},
|
|
9
|
+
"plugins": [
|
|
10
|
+
{
|
|
11
|
+
"name": "cafe-car",
|
|
12
|
+
"source": "./",
|
|
13
|
+
"strict": false,
|
|
14
|
+
"description": "The CafeCar Agent Skill: point a controller at a model and the Pundit policy drives the whole UI — index, show, new, edit, filtering, sorting, CSV export, and Turbo-morph live updates. Teaches agents to extend CafeCar's defaults instead of hand-rolling admin views.",
|
|
15
|
+
"author": {
|
|
16
|
+
"name": "Jeff Peterson"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"homepage": "https://github.com/craft-concept/cafe_car",
|
|
20
|
+
"skills": ["./skills/cafe_car"]
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
data/README.md
CHANGED
|
@@ -4,38 +4,76 @@
|
|
|
4
4
|
CafeCar
|
|
5
5
|
</h1>
|
|
6
6
|
|
|
7
|
+
<p><em>🚋 Recline in the cafe car while Rails renders your views for you.</em></p>
|
|
8
|
+
|
|
7
9
|
[](https://github.com/craft-concept/cafe_car/actions/workflows/ci.yml)
|
|
8
10
|
[](https://rubygems.org/gems/cafe_car)
|
|
9
11
|
[](https://opensource.org/licenses/MIT)
|
|
10
12
|
|
|
11
|
-
> 🚀 **[Live demo →](https://cafe-car-demo
|
|
12
|
-
> into a real
|
|
13
|
-
> the data resets periodically.
|
|
13
|
+
> 🚀 **[Live demo →](https://cafe-car-demo.up.railway.app)** — click straight
|
|
14
|
+
> into a real admin, rendered from plain models (clients, invoices, articles, users, notes).
|
|
15
|
+
> No signup; the data resets periodically.
|
|
14
16
|
|
|
15
17
|
<p align="center">
|
|
16
|
-
<a href="https://cafe-car-demo
|
|
18
|
+
<a href="https://cafe-car-demo.up.railway.app/admin/invoices">
|
|
17
19
|
<img src="https://raw.githubusercontent.com/craft-concept/cafe_car/main/docs/images/admin-invoices-index.png"
|
|
18
|
-
alt="CafeCar
|
|
20
|
+
alt="A CafeCar admin rendered straight from the model: an invoices index with sortable columns, currency formatting, association links, sender avatars, and pagination — no view code."
|
|
19
21
|
width="900">
|
|
20
22
|
</a>
|
|
21
23
|
</p>
|
|
22
24
|
<p align="center">
|
|
23
25
|
<em>A complete admin index — sortable columns, formatted values, association links, and
|
|
24
|
-
pagination —
|
|
25
|
-
<a href="https://cafe-car-demo
|
|
26
|
+
pagination — rendered from a model with one line of controller code.
|
|
27
|
+
<a href="https://cafe-car-demo.up.railway.app">Try the live demo →</a></em>
|
|
26
28
|
</p>
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
Your model already knows its columns, types, and associations — a full
|
|
31
|
+
description of a resource. Rails still makes you hand-write a controller, seven
|
|
32
|
+
actions, and a folder of view templates before any of it renders in a browser.
|
|
33
|
+
CafeCar closes that gap. It's a composable view extension for Rails — an
|
|
34
|
+
extension of the view and controller layer that renders index, show, new, and
|
|
35
|
+
edit straight from the model, with Pundit authorization, filtering, and Hotwire,
|
|
36
|
+
from one line of controller code. It's how Rails ought to work out of the box:
|
|
37
|
+
render something for your models by default, then get out of the way. Every
|
|
38
|
+
default is a starting point — override any view, presenter, or policy with
|
|
39
|
+
ordinary Rails when the default is wrong.
|
|
40
|
+
|
|
41
|
+
**Perfect for**: Rails developers who need a working admin this week — not a
|
|
42
|
+
second framework to learn and configure.
|
|
43
|
+
|
|
44
|
+
## Try it in 60 seconds
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# 1. Install the gem and run the installer
|
|
48
|
+
bundle add cafe_car
|
|
49
|
+
bin/rails generate cafe_car:install
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# 2. Point a controller at a model (add `resources :products` to your routes)
|
|
54
|
+
class ProductsController < ApplicationController
|
|
55
|
+
cafe_car
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# 3. Say who can do what — and which fields are editable
|
|
61
|
+
class ProductPolicy < ApplicationPolicy
|
|
62
|
+
def index? = user.present?
|
|
63
|
+
def update? = user.admin?
|
|
64
|
+
|
|
65
|
+
def permitted_attributes = %i[name price description]
|
|
66
|
+
end
|
|
67
|
+
```
|
|
33
68
|
|
|
34
|
-
|
|
69
|
+
Visit `/products`: index, show, new, and edit, all generated from the model.
|
|
70
|
+
When a default is wrong, override that one piece — see
|
|
71
|
+
[Getting Started](#getting-started).
|
|
35
72
|
|
|
36
73
|
## Table of Contents
|
|
37
74
|
|
|
38
75
|
- [How CafeCar compares](#how-cafecar-compares)
|
|
76
|
+
- [How CafeCar relates to ViewComponent & Phlex](#how-cafecar-relates-to-viewcomponent--phlex)
|
|
39
77
|
- [Features](#features)
|
|
40
78
|
- [Prerequisites](#prerequisites)
|
|
41
79
|
- [Installation](#installation)
|
|
@@ -55,6 +93,7 @@ an application-wide or model-specific basis.
|
|
|
55
93
|
- [Policy Generator](#policy-generator)
|
|
56
94
|
- [Notes Generator](#notes-generator)
|
|
57
95
|
- [Sessions Generator](#sessions-generator)
|
|
96
|
+
- [Using CafeCar with AI coding agents](#using-cafecar-with-ai-coding-agents)
|
|
58
97
|
- [Configuration](#configuration)
|
|
59
98
|
- [Testing](#testing)
|
|
60
99
|
- [Contributing](#contributing)
|
|
@@ -82,10 +121,27 @@ Reach for one of them when its model matches how you want to work:
|
|
|
82
121
|
Reach for CafeCar when you want a Rails-native, convention-over-configuration
|
|
83
122
|
admin that you extend with ordinary Rails code.
|
|
84
123
|
|
|
124
|
+
## How CafeCar relates to ViewComponent & Phlex
|
|
125
|
+
|
|
126
|
+
ViewComponent and Phlex answer "what's the unit of reuse for the UI I write?"
|
|
127
|
+
CafeCar answers a different question: "why hand-write the CRUD index, show, and
|
|
128
|
+
form views at all?" They sit at different layers, so they compose rather than
|
|
129
|
+
compete.
|
|
130
|
+
|
|
131
|
+
CafeCar renders index, show, new, and edit straight from the model — the
|
|
132
|
+
boilerplate you'd otherwise write once per resource and maintain forever. Keep
|
|
133
|
+
your ViewComponent or Phlex components for the screens you customize, and drop
|
|
134
|
+
them into a CafeCar view or presenter like any other partial. CafeCar covers the
|
|
135
|
+
boilerplate screens; your component library covers the parts worth building by hand.
|
|
136
|
+
|
|
137
|
+
Plain partials work the same way. Pick whichever view primitive you like —
|
|
138
|
+
CafeCar is the convention layer above it, deleting the boilerplate views so you
|
|
139
|
+
write only the ones that earn their keep.
|
|
140
|
+
|
|
85
141
|
## Features
|
|
86
142
|
|
|
87
|
-
- 🚀 **
|
|
88
|
-
|
|
143
|
+
- 🚀 **Index, show, new, edit from the model** - One line of controller code
|
|
144
|
+
renders all four, straight from your model
|
|
89
145
|
- 🎨 **Component-based UI system** - Flexible, composable components for
|
|
90
146
|
building interfaces
|
|
91
147
|
- 🔐 **Built-in authorization** - Pundit integration for attribute-level
|
|
@@ -95,11 +151,19 @@ admin that you extend with ordinary Rails code.
|
|
|
95
151
|
association filters
|
|
96
152
|
- 🔎 **Keyword search** - Turnkey search box on every index, matching across a
|
|
97
153
|
model's text columns with zero per-model setup
|
|
154
|
+
- 📈 **Chart view** - A third index view (beside grid/table) that buckets records
|
|
155
|
+
over any date column and plots the count, or a sum/average of a numeric column,
|
|
156
|
+
as dependency-free inline SVG
|
|
157
|
+
- 📊 **Dashboard** - An opt-in overview page composing metric tiles and charts;
|
|
158
|
+
you write one view to turn it on, off by default
|
|
98
159
|
- ⬇️ **CSV export** - One-click "Download CSV" of the current filtered, sorted
|
|
99
160
|
view on any index
|
|
161
|
+
- ☑️ **Bulk actions** - Select rows and act on many at once (delete ships built
|
|
162
|
+
in); every selected record is authorized against your policy on its own
|
|
100
163
|
- 📄 **Pagination & sorting** - Kaminari integration with sortable columns
|
|
101
164
|
- ⚡ **Hotwire ready** - Turbo Streams support out of the box
|
|
102
|
-
- 📝 **Intelligent forms** -
|
|
165
|
+
- 📝 **Intelligent forms** - Forms rendered from your schema, with smart field
|
|
166
|
+
detection
|
|
103
167
|
|
|
104
168
|
## Prerequisites
|
|
105
169
|
|
|
@@ -223,6 +287,8 @@ end
|
|
|
223
287
|
- **Callbacks**: Lifecycle hooks for `render`, `update`, `create`, `destroy`
|
|
224
288
|
- **Responders**: JSON, HTML, and Turbo Stream responses
|
|
225
289
|
|
|
290
|
+
[See it live →](https://cafe-car-demo.up.railway.app/admin/clients) — the clients index from that one line.
|
|
291
|
+
|
|
226
292
|
**Limiting actions:**
|
|
227
293
|
|
|
228
294
|
```ruby
|
|
@@ -280,7 +346,7 @@ end
|
|
|
280
346
|
**Key methods:**
|
|
281
347
|
|
|
282
348
|
- `permitted_attributes` - Attributes that can be edited via forms
|
|
283
|
-
- `
|
|
349
|
+
- `attributes.displayable` - Attributes shown in views (auto-detected from
|
|
284
350
|
columns + associations)
|
|
285
351
|
- `displayable_associations` - Associations that can be displayed
|
|
286
352
|
- `filtered_attribute?(attr)` - Check if attribute should be hidden (uses Rails
|
|
@@ -419,15 +485,15 @@ end
|
|
|
419
485
|
CafeCar provides an enhanced form builder with smart field detection.
|
|
420
486
|
|
|
421
487
|
<p align="center">
|
|
422
|
-
<a href="https://cafe-car-demo
|
|
488
|
+
<a href="https://cafe-car-demo.up.railway.app/admin/invoices/new">
|
|
423
489
|
<img src="https://raw.githubusercontent.com/craft-concept/cafe_car/main/docs/images/admin-invoice-form.png"
|
|
424
|
-
alt="CafeCar
|
|
490
|
+
alt="A CafeCar new-invoice form rendered from the model, with a client association select, typed inputs, and nested has_many line items (add/remove rows)."
|
|
425
491
|
width="760">
|
|
426
492
|
</a>
|
|
427
493
|
</p>
|
|
428
494
|
<p align="center">
|
|
429
|
-
<em>
|
|
430
|
-
<code>has_many</code> line items — add and remove rows inline.</em>
|
|
495
|
+
<em>A form rendered from the model, with a typed date field, an association select, and
|
|
496
|
+
nested <code>has_many</code> line items — add and remove rows inline.</em>
|
|
431
497
|
</p>
|
|
432
498
|
|
|
433
499
|
**Basic forms:**
|
|
@@ -470,7 +536,17 @@ The form builder automatically detects field types:
|
|
|
470
536
|
<%= f.association :category %>
|
|
471
537
|
```
|
|
472
538
|
|
|
473
|
-
|
|
539
|
+
[See it live →](https://cafe-car-demo.up.railway.app/admin/invoices/new) — add and remove nested line items on a new invoice.
|
|
540
|
+
|
|
541
|
+
Creates a select dropdown for the association. The select is **searchable**: it's
|
|
542
|
+
enhanced with [Tom Select](https://tom-select.js.org) (vendored — no CDN, no
|
|
543
|
+
bundler) for keystroke typeahead. To keep large associations from rendering
|
|
544
|
+
thousands of `<option>`s, the initial list is capped at
|
|
545
|
+
`CafeCar.max_collection_options` (default 100); the typeahead then queries a JSON
|
|
546
|
+
`options` endpoint (`GET /categories/options?q=…`) so records **past the cap stay
|
|
547
|
+
reachable**. That endpoint is authorized through the model's policy scope, so the
|
|
548
|
+
search never returns rows the user can't see. Enhancement is progressive — if
|
|
549
|
+
JavaScript is disabled or fails, the field stays a working plain select.
|
|
474
550
|
|
|
475
551
|
### Filtering & Sorting
|
|
476
552
|
|
|
@@ -482,14 +558,78 @@ CafeCar provides advanced filtering with minimal configuration.
|
|
|
482
558
|
/products?name=Widget&price.min=10&price.max=50&created_at=2024-01-01..2024-12-31
|
|
483
559
|
```
|
|
484
560
|
|
|
485
|
-
**Filter operators
|
|
561
|
+
**Filter operators** — a bare column key filters that column; a `.operator`
|
|
562
|
+
suffix compares it:
|
|
563
|
+
|
|
564
|
+
- **Equals**: `status=active` (or `status.eq=active`)
|
|
565
|
+
- **Greater / less than**: `price.gt=10`, `price.lt=50`
|
|
566
|
+
- **At least / at most**: `price.min=10`, `price.max=50` (aliases: `price.gte`, `price.lte`)
|
|
567
|
+
- **Range**: `created_at=2024-01-01..2024-12-31` (`...` for an exclusive end)
|
|
568
|
+
- **Arrays (IN)**: `tags=red,blue,green`
|
|
569
|
+
|
|
570
|
+
Combine them freely — `?price.min=10&price.max=50` reads as `price BETWEEN 10 AND 50`.
|
|
571
|
+
|
|
572
|
+
[See it live →](https://cafe-car-demo.up.railway.app/admin/users) — sort, filter, and search a real index.
|
|
573
|
+
|
|
574
|
+
**The filter panel:**
|
|
575
|
+
|
|
576
|
+
Every index also renders those filters as a form — a sticky card of controls
|
|
577
|
+
beside the table, with no configuration. CafeCar reads the model policy's
|
|
578
|
+
`permitted_filters` (which defaults to the displayable columns and associations)
|
|
579
|
+
and renders one **typed control per attribute**, matched to the column type:
|
|
580
|
+
|
|
581
|
+
- **string / text** — a "contains" box (substring match, the `~` operator)
|
|
582
|
+
- **integer / decimal / float / date / datetime** — a min/max pair (the range)
|
|
583
|
+
- **enum** — a select of the enum's own values (blank = Any)
|
|
584
|
+
- **boolean** — a tri-state select (Any / true / false)
|
|
585
|
+
- **belongs_to / has_many** — a searchable **multi-select** of the associated
|
|
586
|
+
records (the same Tom Select typeahead as the edit form), matching any of the
|
|
587
|
+
records you pick
|
|
588
|
+
|
|
589
|
+
The keyword-search box shares the card, and the whole panel submits as one GET
|
|
590
|
+
form — a submission just rewrites the query string above, with the active sort
|
|
591
|
+
and view riding along as hidden fields. Attachments, rich text, polymorphic
|
|
592
|
+
targets, and password columns render no control (there's nothing to filter on).
|
|
593
|
+
|
|
594
|
+
Named model scopes opt in separately through `permitted_scopes` and render as
|
|
595
|
+
checkbox toggles:
|
|
596
|
+
|
|
597
|
+
```ruby
|
|
598
|
+
class ArticlePolicy < ApplicationPolicy
|
|
599
|
+
def permitted_filters = %i[title author published_at] # narrow the panel
|
|
600
|
+
def permitted_scopes = %i[published draft] # ?published=true
|
|
601
|
+
end
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
The policy is the source of truth: change `permitted_filters` to change which
|
|
605
|
+
controls appear, and a URL filter for an attribute that isn't on the list is
|
|
606
|
+
dropped before it reaches the query. Control labels read from your locale
|
|
607
|
+
(`helpers.filter`, plus each model's attribute names), and you replace any
|
|
608
|
+
control by dropping a `_<type>_filter.html.haml` partial in the view directory.
|
|
609
|
+
|
|
610
|
+
**Filter through an association:**
|
|
611
|
+
|
|
612
|
+
Add a dot-path to `permitted_filters` and the panel filters by the far model's
|
|
613
|
+
attribute:
|
|
614
|
+
|
|
615
|
+
```ruby
|
|
616
|
+
class InvoicePolicy < ApplicationPolicy
|
|
617
|
+
# own columns, plus a client's status and its owner
|
|
618
|
+
def permitted_filters = %i[status client.status client.owner_id]
|
|
619
|
+
end
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
Each hop is the association name; the terminal is the far column, its enum, or a
|
|
623
|
+
belongs_to — `client.owner_id` and `client.owner` mean the same thing. The
|
|
624
|
+
control is typed by that terminal, so a nested belongs_to renders the same
|
|
625
|
+
searchable multi-select and a nested enum the same enum select as a top-level
|
|
626
|
+
one, and it composes with the other filters, search, and sort, round-tripping
|
|
627
|
+
into the URL like any other.
|
|
486
628
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
- **Equals**: `status=active` or `status.eq=active`
|
|
492
|
-
- **Arrays**: `tags=red,blue,green`
|
|
629
|
+
That one list is both the panel's source and the query whitelist: an undeclared
|
|
630
|
+
path is dropped before any join, even when it names a real column further out.
|
|
631
|
+
`?client.owner.email=` filters nothing unless `client.owner.email` is on
|
|
632
|
+
`permitted_filters` — the same gate as an unpermitted top-level column.
|
|
493
633
|
|
|
494
634
|
**Sorting:**
|
|
495
635
|
|
|
@@ -544,6 +684,41 @@ and logs a warning. Raise the limit in an initializer if your tables are larger:
|
|
|
544
684
|
CafeCar.csv_export_row_limit = 50_000
|
|
545
685
|
```
|
|
546
686
|
|
|
687
|
+
**Chart view:**
|
|
688
|
+
|
|
689
|
+
Every index offers a third view beside grid and table. The **Chart** toggle
|
|
690
|
+
buckets records over a date column and plots them as a bar chart:
|
|
691
|
+
|
|
692
|
+
```
|
|
693
|
+
/articles?view=chart&chart_x=published_at&chart_by=month
|
|
694
|
+
/invoices?view=chart&chart_x=issued_on&chart_y=sum:total
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
Pick the x-axis from any of the model's displayable date/datetime columns
|
|
698
|
+
(`chart_x`) and the bucket size from `day`, `week`, or `month` (`chart_by`,
|
|
699
|
+
default `month`) using the form above the chart — it defaults to `created_at`
|
|
700
|
+
and month, so the chart renders with zero configuration. Only date columns the
|
|
701
|
+
policy exposes are offered, and the selected column is validated against that
|
|
702
|
+
allowlist, so the parameter is never used as a raw column name.
|
|
703
|
+
|
|
704
|
+
By default the bars measure the record count per bucket. `chart_y` switches that
|
|
705
|
+
to an aggregate of a numeric column: `count` (the default), `sum:<column>`, or
|
|
706
|
+
`avg:<column>` — for example `chart_y=sum:total`. The chartable columns are the
|
|
707
|
+
model's policy-permitted numeric attributes (integer, decimal, or float), the
|
|
708
|
+
same policy-is-source-of-truth pattern as the x-axis dates. A `chart_y` selector
|
|
709
|
+
renders above the chart only when the model has at least one such column;
|
|
710
|
+
otherwise you get the count chart with no selector. An unknown or non-permitted
|
|
711
|
+
`chart_y` is validated against the allowlist and falls back to `count`, so the
|
|
712
|
+
value never reaches SQL raw. Sum and average aggregate through a portable
|
|
713
|
+
ActiveRecord calculation that runs on both SQLite and Postgres.
|
|
714
|
+
|
|
715
|
+
The chart aggregates the **same** collection the table shows: the active filters
|
|
716
|
+
narrow it and `policy_scope` still applies, so it never plots rows the current
|
|
717
|
+
user can't see. Aggregation is a single database `GROUP BY` (a portable Arel date
|
|
718
|
+
truncation — `date_trunc` on Postgres, `strftime` on SQLite), and the result is
|
|
719
|
+
dependency-free inline SVG — no JavaScript, CSP-safe. A model with no date column
|
|
720
|
+
shows a short "no date columns to chart" message instead.
|
|
721
|
+
|
|
547
722
|
**In models:**
|
|
548
723
|
|
|
549
724
|
```ruby
|
|
@@ -625,6 +800,159 @@ In views:
|
|
|
625
800
|
<% end %>
|
|
626
801
|
```
|
|
627
802
|
|
|
803
|
+
### Bulk Actions
|
|
804
|
+
|
|
805
|
+
Every index table carries a checkbox per row and a "select all" checkbox in the
|
|
806
|
+
header. Pick some rows, choose an action from the bar, and CafeCar applies it to
|
|
807
|
+
the selection. **Delete** ships built in.
|
|
808
|
+
|
|
809
|
+
Each selected record is authorized on its own: the candidate set is first
|
|
810
|
+
narrowed to the policy scope, then every row is checked against the action's
|
|
811
|
+
policy predicate (`destroy?` for delete). Rows the current user isn't allowed to
|
|
812
|
+
touch are skipped — a batch never bulk-bypasses a per-record denial.
|
|
813
|
+
|
|
814
|
+
**The policy is the source of truth.** A policy declares the actions its index
|
|
815
|
+
offers with `permitted_bulk_actions`, and the bar renders exactly that list:
|
|
816
|
+
|
|
817
|
+
```ruby
|
|
818
|
+
class ArticlePolicy < ApplicationPolicy
|
|
819
|
+
def permitted_bulk_actions = %i[publish destroy]
|
|
820
|
+
|
|
821
|
+
def publish? = user.editor? # per-record authorization for the batch
|
|
822
|
+
def destroy? = !object.published?
|
|
823
|
+
end
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
A custom action "just works" from three conventional pieces — no registration:
|
|
827
|
+
|
|
828
|
+
1. list its name in `permitted_bulk_actions` (the policy),
|
|
829
|
+
2. a **model bang method** for the behavior (`Article#publish!`),
|
|
830
|
+
3. a **policy predicate** for authorization (`publish?`).
|
|
831
|
+
|
|
832
|
+
`batch` derives both from the name — it calls `record.publish!` on each row the
|
|
833
|
+
policy answers `publish?` for. Button labels and styles come from your locale
|
|
834
|
+
(`en.destroy: Delete`; `bulk_actions.styles.destroy: danger`), and you override
|
|
835
|
+
the whole bar for a resource by dropping an `_bulk_actions.html.haml` partial in
|
|
836
|
+
its view directory.
|
|
837
|
+
|
|
838
|
+
[See it live →](https://cafe-car-demo.up.railway.app/admin/users) — select rows and delete the selection.
|
|
839
|
+
|
|
840
|
+
### Member Actions
|
|
841
|
+
|
|
842
|
+
A **member action** runs one custom operation on a single record — a button on
|
|
843
|
+
the show page and on each index row, no row selection. It's the same three
|
|
844
|
+
conventional pieces as a bulk action, declared with `permitted_member_actions`:
|
|
845
|
+
|
|
846
|
+
```ruby
|
|
847
|
+
class ArticlePolicy < ApplicationPolicy
|
|
848
|
+
def permitted_member_actions = %i[publish]
|
|
849
|
+
|
|
850
|
+
def publish? = !object.published? # authorizes the record, and shows/hides the button
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
class Article < ApplicationRecord
|
|
854
|
+
def publish! = update!(published_at: Time.current)
|
|
855
|
+
end
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
The name resolves by convention — no registration: `publish?` authorizes and
|
|
859
|
+
`Article#publish!` runs. CafeCar renders a **Publish** button on the show page's
|
|
860
|
+
Actions card and on the record's index row, each a POST to
|
|
861
|
+
`/articles/:id/actions/publish`. A record whose `publish?` answers false renders
|
|
862
|
+
disabled with a tooltip instead of a link, so the one predicate gates both the
|
|
863
|
+
display and the request — a forged POST is re-authorized and refused, and the
|
|
864
|
+
bang method never runs.
|
|
865
|
+
|
|
866
|
+
The action replies with a redirect that Turbo morphs in place, so the row
|
|
867
|
+
reflows without a full page reload. The button's label and style come from your
|
|
868
|
+
locale (`en.publish`; `actions.styles.publish: primary`), never a hardcoded
|
|
869
|
+
string.
|
|
870
|
+
|
|
871
|
+
Override the forwarding by defining a `publish` controller method — it takes
|
|
872
|
+
over after authorization and owns the response. Override the buttons by dropping
|
|
873
|
+
an `_actions.html.haml` (show) or `_controls.html.haml` (index) partial in the
|
|
874
|
+
resource's view directory.
|
|
875
|
+
|
|
876
|
+
[See it live →](https://cafe-car-demo.up.railway.app/admin/articles) — publish an article from its show page or index row.
|
|
877
|
+
|
|
878
|
+
### Collection Actions
|
|
879
|
+
|
|
880
|
+
A **collection action** runs one action over a whole set at once, from a single
|
|
881
|
+
toolbar button — no row selection. It's the same three conventional pieces as a
|
|
882
|
+
bulk action, declared with `permitted_collection_actions`:
|
|
883
|
+
|
|
884
|
+
```ruby
|
|
885
|
+
class ArticlePolicy < ApplicationPolicy
|
|
886
|
+
def permitted_collection_actions = %i[publish_all]
|
|
887
|
+
|
|
888
|
+
def publish_all? = user.editor? # authorizes against the model class
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
class Article < ApplicationRecord
|
|
892
|
+
def self.publish_all! = unpublished.update_all(published_at: Time.current)
|
|
893
|
+
end
|
|
894
|
+
```
|
|
895
|
+
|
|
896
|
+
The button runs over the **currently-viewed, filtered set** — not the whole
|
|
897
|
+
table. It carries the active filters and search from the URL, so the action hits
|
|
898
|
+
exactly the records the index is showing. Filter `?author_id=7`, and **Publish
|
|
899
|
+
all** publishes that author's articles and nothing else. The button's label shows
|
|
900
|
+
the reach as a count — **Publish all 21** — from your locale
|
|
901
|
+
(`helpers.collection_action`, a `one`/`other` hash you can reword).
|
|
902
|
+
|
|
903
|
+
Names resolve like member/bulk actions: `publish_all?` authorizes, then
|
|
904
|
+
`Article.publish_all!` runs within the filtered scope. Override the forwarding by
|
|
905
|
+
defining a `publish_all` controller method (scope its own query), and override
|
|
906
|
+
the toolbar by dropping an `_index_actions.html.haml` partial in the view directory.
|
|
907
|
+
|
|
908
|
+
### Dashboard
|
|
909
|
+
|
|
910
|
+
CafeCar can render a single **dashboard** overview — an at-a-glance page that
|
|
911
|
+
composes your data into metric tiles and charts. It's **opt-in the CafeCar way:
|
|
912
|
+
you write one view.** Its existence turns the dashboard on; delete it and there's
|
|
913
|
+
no dashboard at all (a direct hit 404s, no nav link), so a CRUD-only app never
|
|
914
|
+
inherits a blank page.
|
|
915
|
+
|
|
916
|
+
Write `app/views/cafe_car/dashboard/show.html.haml`:
|
|
917
|
+
|
|
918
|
+
```haml
|
|
919
|
+
- title "Dashboard"
|
|
920
|
+
|
|
921
|
+
= Page title: "Dashboard" do |page|
|
|
922
|
+
= page.Body do
|
|
923
|
+
.Dashboard
|
|
924
|
+
= metrics User
|
|
925
|
+
= metric("Signups today") { User.where(created_at: Date.current.all_day).count }
|
|
926
|
+
= chart "New users", model: User, x: :created_at, by: :month
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
Three helpers compose the page:
|
|
930
|
+
|
|
931
|
+
- **`metric("Label") { … }`** — a tile showing a label over the number your block
|
|
932
|
+
returns.
|
|
933
|
+
- **`metrics(Model)`** — the tiles a **policy** declares. `Model`'s policy lists
|
|
934
|
+
the scopes to surface in `permitted_metrics` (`:all` = the whole relation), and
|
|
935
|
+
CafeCar renders a count tile for each — the same policy-is-source-of-truth rule
|
|
936
|
+
as bulk actions.
|
|
937
|
+
- **`chart "Title", model:, x:, by:`** — the same dependency-free inline-SVG bar
|
|
938
|
+
chart as the index [Chart view](#advanced-usage), bucketing `model`'s records
|
|
939
|
+
over the `x` date column at `by` granularity (`:day`/`:week`/`:month`, default
|
|
940
|
+
`:month`). The `x` column is validated against the model's date-column allowlist
|
|
941
|
+
and truncated with portable Arel, so it's never interpolated as raw SQL.
|
|
942
|
+
|
|
943
|
+
```ruby
|
|
944
|
+
class UserPolicy < ApplicationPolicy
|
|
945
|
+
def permitted_metrics = %i[all active] # total, then User.active.count
|
|
946
|
+
end
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
Tiles render in a responsive grid at `dashboard_path` (no JavaScript, CSP-safe).
|
|
950
|
+
Because the dashboard is a plain view, you can drop in headings, your own
|
|
951
|
+
partials, or any markup between tiles. Once the template exists, a **Dashboard**
|
|
952
|
+
link appears at the top of the sidebar nav — no template, no link.
|
|
953
|
+
|
|
954
|
+
[See it live →](https://cafe-car-demo.up.railway.app/admin/dashboard) — metric tiles and a chart on one page.
|
|
955
|
+
|
|
628
956
|
### Current Context
|
|
629
957
|
|
|
630
958
|
Access current request context anywhere:
|
|
@@ -760,8 +1088,66 @@ $ rails generate cafe_car:sessions
|
|
|
760
1088
|
Creates the `sessions` table migration. The `CafeCar::Session` model and
|
|
761
1089
|
`SessionPolicy` already ship with the engine.
|
|
762
1090
|
|
|
1091
|
+
## Using CafeCar with AI coding agents
|
|
1092
|
+
|
|
1093
|
+
CafeCar ships an [Agent Skill](skills/cafe_car/SKILL.md) — the mental model (the policy
|
|
1094
|
+
declares, the UI renders) plus ten reference pages, written for coding agents. Install it
|
|
1095
|
+
into your app:
|
|
1096
|
+
|
|
1097
|
+
```bash
|
|
1098
|
+
$ rails generate cafe_car:agents
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
This copies the skill to `.claude/skills/cafe_car/` (Claude Code) and
|
|
1102
|
+
`.agents/skills/cafe_car/` (Codex, Copilot, and other agents), and adds a marker-delimited
|
|
1103
|
+
pointer block to your `AGENTS.md` so agents read the skill before hand-rolling admin UI.
|
|
1104
|
+
Safe to re-run: only the marked block is replaced; the rest of your `AGENTS.md` is never
|
|
1105
|
+
touched.
|
|
1106
|
+
|
|
1107
|
+
Two more ways to put CafeCar docs in front of an agent:
|
|
1108
|
+
|
|
1109
|
+
```bash
|
|
1110
|
+
$ npx skills add craft-concept/cafe_car # install the skill in any skills-aware tool
|
|
1111
|
+
```
|
|
1112
|
+
|
|
1113
|
+
Or point an MCP-capable agent at
|
|
1114
|
+
[gitmcp.io/craft-concept/cafe_car](https://gitmcp.io/craft-concept/cafe_car) for live
|
|
1115
|
+
CafeCar docs with zero setup.
|
|
1116
|
+
|
|
763
1117
|
## Configuration
|
|
764
1118
|
|
|
1119
|
+
### Theme
|
|
1120
|
+
|
|
1121
|
+
CafeCar ships three bundled themes, each a set of CSS custom properties with a
|
|
1122
|
+
built-in `prefers-color-scheme: dark` variant. Pick one with `CafeCar.theme`:
|
|
1123
|
+
|
|
1124
|
+
```ruby
|
|
1125
|
+
# config/initializers/cafe_car.rb
|
|
1126
|
+
CafeCar.theme = :cool
|
|
1127
|
+
```
|
|
1128
|
+
|
|
1129
|
+
| Theme | Look |
|
|
1130
|
+
| -------- | --------------------------------------------------------- |
|
|
1131
|
+
| `:warm` | Warm neutrals on off-white — the default. |
|
|
1132
|
+
| `:cool` | Cool blue-grey on a crisp light background. |
|
|
1133
|
+
| `:cool2` | The `cool` palette with translucent cards and darker dark mode. |
|
|
1134
|
+
|
|
1135
|
+
The selected theme is injected as a `<link>` into every CafeCar page's `<head>`,
|
|
1136
|
+
so it takes effect without recompiling assets. It defaults to `:warm` (the theme
|
|
1137
|
+
the engine has always shipped); an unknown value raises `ArgumentError`.
|
|
1138
|
+
|
|
1139
|
+
### Association select size
|
|
1140
|
+
|
|
1141
|
+
`f.association` selects render at most `CafeCar.max_collection_options` options
|
|
1142
|
+
(default 100) to keep a large association from loading its whole target table into
|
|
1143
|
+
every form. Records past the cap stay reachable through the searchable select's
|
|
1144
|
+
typeahead feed (see [Forms](#forms)). Raise or lower the cap globally:
|
|
1145
|
+
|
|
1146
|
+
```ruby
|
|
1147
|
+
# config/initializers/cafe_car.rb
|
|
1148
|
+
CafeCar.max_collection_options = 250
|
|
1149
|
+
```
|
|
1150
|
+
|
|
765
1151
|
### Custom Form Builder
|
|
766
1152
|
|
|
767
1153
|
```ruby
|
|
@@ -827,6 +1213,8 @@ development setup, how to run the tests (`bundle exec rake`), and PR expectation
|
|
|
827
1213
|
By participating you agree to the [Code of Conduct](CODE_OF_CONDUCT.md). To report a
|
|
828
1214
|
security issue, see [SECURITY.md](SECURITY.md).
|
|
829
1215
|
|
|
1216
|
+
If CafeCar saved you an afternoon, a star helps other Rails developers find it.
|
|
1217
|
+
|
|
830
1218
|
## License
|
|
831
1219
|
|
|
832
1220
|
The gem is available as open source under the terms of the
|
data/Rakefile
CHANGED
|
@@ -19,6 +19,6 @@ end
|
|
|
19
19
|
|
|
20
20
|
task default: %i[rubocop test brakeman]
|
|
21
21
|
|
|
22
|
-
#
|
|
23
|
-
#
|
|
22
|
+
# Load the gem's own rake tasks under lib/tasks/ (the backlog now lives on the
|
|
23
|
+
# holdco-tasks board — see `bin/operate tasks`).
|
|
24
24
|
Dir.glob(File.expand_path("lib/tasks/*.rake", __dir__)).sort.each { |f| load f }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
@layer component {
|
|
2
|
+
.ChartControls {
|
|
3
|
+
display: flex;
|
|
4
|
+
gap: var(--half-gap);
|
|
5
|
+
align-items: center;
|
|
6
|
+
margin-block-end: var(--gap);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* Fill the column at the SVG's fixed landscape viewBox ratio (see
|
|
10
|
+
ChartBuilder::WIDTH/HEIGHT). `height: auto` lets the height track that ratio,
|
|
11
|
+
so the chart stays a steady ~3.5:1 landscape at any bucket count instead of
|
|
12
|
+
rendering tall-and-narrow (few buckets) or flat (many). max-height keeps it
|
|
13
|
+
from getting oversized on very wide screens; the SVG's preserveAspectRatio
|
|
14
|
+
letterboxes cleanly if the cap trims the ratio. */
|
|
15
|
+
.Chart {
|
|
16
|
+
display: block;
|
|
17
|
+
width: 100%;
|
|
18
|
+
height: auto;
|
|
19
|
+
max-height: 360px;
|
|
20
|
+
color: var(--accent);
|
|
21
|
+
overflow: visible;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.Chart-bar rect {
|
|
25
|
+
transition: opacity var(--duration);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.Chart-bar:hover rect {
|
|
29
|
+
opacity: 0.8;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.Chart-value {
|
|
33
|
+
fill: var(--dim);
|
|
34
|
+
font-size: 11px;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.Chart-label {
|
|
38
|
+
fill: var(--dim);
|
|
39
|
+
font-size: 11px;
|
|
40
|
+
}
|
|
41
|
+
}
|