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
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Components, CSS, theming
|
|
2
|
+
|
|
3
|
+
Source: `lib/cafe_car/component.rb`, declarations in `app/ui/cafe_car/ui/*.rb`,
|
|
4
|
+
styles in `app/assets/stylesheets/` (all in the gem). This is CafeCar's own
|
|
5
|
+
component system — not ViewComponent or Phlex. Any capitalized call in a view is a
|
|
6
|
+
component.
|
|
7
|
+
|
|
8
|
+
## Using components
|
|
9
|
+
|
|
10
|
+
```haml
|
|
11
|
+
= Card title: object.title, image: object.logo, actions: object.controls do |card|
|
|
12
|
+
= card.Section object.show(:price)
|
|
13
|
+
= card.Foot do
|
|
14
|
+
= Button :primary, href: href_for(record) do
|
|
15
|
+
= t(:show)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- **Flags** are bare symbols: `Button :primary`, `Button :danger`, `Card :slim`,
|
|
19
|
+
`Page :slim, :center`. They become CSS modifier classes (`Button-primary`).
|
|
20
|
+
- **Options** are keywords the component declares (`title:`, `image:`, `actions:`,
|
|
21
|
+
`href:`, `tip:`, `tag:`, `data:`). Anything with `href:` renders as a link and
|
|
22
|
+
gets `current`/`ancestor` classes automatically.
|
|
23
|
+
- **Children** are capitalized methods on the block arg: `card.Section`,
|
|
24
|
+
`card.Foot`, `page.Body`, `page.Aside`. Undeclared children work too — they
|
|
25
|
+
become nested class names (`a.Title` inside `Article` → `Article_Title`).
|
|
26
|
+
- A component that captures blank content renders nothing — no empty wrappers.
|
|
27
|
+
|
|
28
|
+
Shipped components include `Page`, `Card`, `Grid`, `Row`, `Group`, `Button`,
|
|
29
|
+
`Field`, `Table`, `Alert`, `Menu`, `Navigation`, `Modal`, `Icon`, `Controls`.
|
|
30
|
+
Declarations live in the gem's `app/ui/cafe_car/ui/` (e.g. `Card` declares its
|
|
31
|
+
flags, options, and named children there).
|
|
32
|
+
|
|
33
|
+
## Custom components — drop a partial
|
|
34
|
+
|
|
35
|
+
No registration. Calling `ui.Badge` (or `= Badge` in a view) looks for a partial at
|
|
36
|
+
`ui/badge`; give it one:
|
|
37
|
+
|
|
38
|
+
```haml
|
|
39
|
+
-# app/views/cafe_car/ui/_badge.html.haml
|
|
40
|
+
%span{ class: badge.class_name }= yield
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Locals: the component instance under its name (`badge`), plus `options` and
|
|
44
|
+
`flags`. The same mechanism overrides the markup of a *shipped* component — a host
|
|
45
|
+
`app/views/cafe_car/ui/_card.html.haml` replaces Card's markup app-wide while
|
|
46
|
+
keeping its Ruby API.
|
|
47
|
+
|
|
48
|
+
For behavior-bearing components, declare a class the way the gem does:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
# app/ui/cafe_car/ui/badge.rb (host)
|
|
52
|
+
module CafeCar
|
|
53
|
+
module UI
|
|
54
|
+
component :Badge do
|
|
55
|
+
flag :success
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## CSS — no styles outside components
|
|
62
|
+
|
|
63
|
+
Owner rule: global CSS is banned; it breaks components reused in unexpected places.
|
|
64
|
+
Every component has a scoped stylesheet (gem: `app/assets/stylesheets/ui/Card.css`
|
|
65
|
+
etc.) selecting its own class (`.Card`, `.Button-danger`). Style a new component
|
|
66
|
+
with its own file selecting its own class names; never restyle tags or other
|
|
67
|
+
components globally. The gem's `cafe_car.css` organizes everything in cascade
|
|
68
|
+
layers (`vendor, default, theme, component, modifier, layout, utility`).
|
|
69
|
+
|
|
70
|
+
## Theming
|
|
71
|
+
|
|
72
|
+
Three bundled themes — sets of CSS custom properties with dark-mode variants:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# config/initializers/cafe_car.rb
|
|
76
|
+
CafeCar.theme = :cool # :warm (default), :cool, :cool2
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The theme is injected as a `<link>` in `<head>` after `application.css`. All tokens
|
|
80
|
+
are defined on `:root` in the gem's `themes/defaults.css`: `--accent`, `--primary`,
|
|
81
|
+
`--danger`, `--card`, `--button`, `--link`, `--font-family`, `--gap`, `--radius`,
|
|
82
|
+
`--page-width`, and friends.
|
|
83
|
+
|
|
84
|
+
**Caveat:** `CafeCar.theme=` only accepts the bundled names (raises otherwise) —
|
|
85
|
+
you cannot register a new named theme. For a custom look, pick the closest bundled
|
|
86
|
+
theme and override its `:root` tokens with CSS loaded *after* the theme link —
|
|
87
|
+
either override the `_head` partial to append your stylesheet after
|
|
88
|
+
`theme_stylesheet_tag`, or use a higher-specificity selector (e.g. `:root:root`)
|
|
89
|
+
in your `application.css`:
|
|
90
|
+
|
|
91
|
+
```css
|
|
92
|
+
:root:root { --accent: #7c3aed; --radius: 6px; }
|
|
93
|
+
```
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Controllers
|
|
2
|
+
|
|
3
|
+
Source: `lib/cafe_car/controller.rb` (the `CafeCar::Controller` concern — usually
|
|
4
|
+
included in the host's `ApplicationController` by the installer).
|
|
5
|
+
|
|
6
|
+
## The macro
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
module Admin
|
|
10
|
+
class ProductsController < ApplicationController
|
|
11
|
+
cafe_car
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`cafe_car` wires the full CRUD surface: `index show new edit create update destroy`,
|
|
17
|
+
plus `batch` (bulk actions) and `options` (association-select typeahead JSON). It
|
|
18
|
+
authorizes every action through Pundit (`verify_authorized` and
|
|
19
|
+
`verify_policy_scoped` are enforced), responds to `:html`, `:json`, `:turbo_stream`,
|
|
20
|
+
and `:csv`, rescues validation failures into a re-rendered form, and appends the
|
|
21
|
+
engine's view fallbacks.
|
|
22
|
+
|
|
23
|
+
The model is inferred from the controller name (`Admin::ProductsController` →
|
|
24
|
+
`Product`). Variants:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
cafe_car only: %i[index show] # limit actions
|
|
28
|
+
cafe_car except: %i[destroy]
|
|
29
|
+
cafe_car model: Company # or the standalone `model Company` macro
|
|
30
|
+
|
|
31
|
+
class AttachmentsController < ApplicationController
|
|
32
|
+
cafe_car
|
|
33
|
+
model ::ActiveStorage::Attachment # point at any model, even a library's
|
|
34
|
+
default_view :grid # index defaults to grid instead of table
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## The scope pipeline
|
|
39
|
+
|
|
40
|
+
Every action reads records through one method:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
def scope = model.all.then { policy_scope _1 }
|
|
44
|
+
.then { sorted _1 }
|
|
45
|
+
.then { filtered _1 }
|
|
46
|
+
.then { eager_loaded _1 }
|
|
47
|
+
.then { paginated _1 }
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`policy_scope` applies the policy's `Scope#resolve`; `sorted` reads `?sort=`;
|
|
51
|
+
`filtered` reads the dot-query params and `?q=`; `eager_loaded` preloads displayed
|
|
52
|
+
associations (no N+1); `paginated` is Kaminari (`?page=`, `?per=` capped at
|
|
53
|
+
`CafeCar.max_per_page`). CSV requests skip pagination and export the whole
|
|
54
|
+
filtered set.
|
|
55
|
+
|
|
56
|
+
Narrow one controller without touching the policy:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
class Admin::PublishedArticlesController < ApplicationController
|
|
60
|
+
cafe_car model: Article
|
|
61
|
+
def scope = super.published
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Prefer the policy `Scope` when the restriction is about *who may see what*; override
|
|
66
|
+
`scope` when it's about *what this particular screen lists*.
|
|
67
|
+
|
|
68
|
+
## Objects and callbacks
|
|
69
|
+
|
|
70
|
+
`object` / `objects` are the current record/collection, also exposed as the
|
|
71
|
+
conventional ivars (`@product`, `@products`). Lifecycle callbacks exist for
|
|
72
|
+
`:create`, `:update`, `:destroy`, and `:render`:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
class ProductsController < ApplicationController
|
|
76
|
+
cafe_car
|
|
77
|
+
|
|
78
|
+
after_create do
|
|
79
|
+
NotificationMailer.product_created(object).deliver_later
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
(`before_`/`around_`/`after_` + `skip_*` helpers are defined for each; blocks run in
|
|
85
|
+
controller context around `object.save!` / `object.destroy!`.)
|
|
86
|
+
|
|
87
|
+
## Extra endpoints
|
|
88
|
+
|
|
89
|
+
Host routes drawn with `resources` automatically gain two collection routes
|
|
90
|
+
(`lib/cafe_car/routing.rb`):
|
|
91
|
+
|
|
92
|
+
- `POST /products/batch` — applies a bulk action to selected ids. The action name
|
|
93
|
+
must be in the policy's `permitted_bulk_actions`; each record is authorized
|
|
94
|
+
individually against `<action>?` then receives `<action>!`. See
|
|
95
|
+
[policies.md](policies.md).
|
|
96
|
+
- `GET /products/options?q=…` — policy-scoped `[{value, text}]` JSON feeding the
|
|
97
|
+
searchable association selects. See [forms.md](forms.md).
|
|
98
|
+
|
|
99
|
+
## Responses
|
|
100
|
+
|
|
101
|
+
Success responds per format: HTML redirects with a locale-driven flash
|
|
102
|
+
(`flashes.create_html` etc.), turbo_stream emits a page refresh (morph — see
|
|
103
|
+
[turbo.md](turbo.md)), JSON serializes `[:id] + attributes.displayable` per the
|
|
104
|
+
policy. Validation failure re-renders `new`/`edit` with `422`. Authorization failure
|
|
105
|
+
returns 403 — or redirects to login when the opt-in sessions are installed
|
|
106
|
+
(`bin/rails g cafe_car:sessions`).
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Filtering, sorting, search
|
|
2
|
+
|
|
3
|
+
Source: `lib/cafe_car/param_parser.rb`, `lib/cafe_car/query_builder.rb`,
|
|
4
|
+
`lib/cafe_car/controller/filtering.rb`, `lib/cafe_car/queryable.rb`,
|
|
5
|
+
`lib/cafe_car/model.rb`.
|
|
6
|
+
|
|
7
|
+
On an index request, every query param that isn't a control param
|
|
8
|
+
(`sort page per view tab q chart_* …`) is a filter. **Never invent bespoke filter
|
|
9
|
+
params for an admin screen — link with this grammar** and the whole pipeline (table,
|
|
10
|
+
grid, chart, CSV export, pagination) honors it.
|
|
11
|
+
|
|
12
|
+
## The dot-query URL grammar
|
|
13
|
+
|
|
14
|
+
| URL | Meaning |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `?name=Widget` | `name = 'Widget'` |
|
|
17
|
+
| `?status.eq=active` | explicit equals |
|
|
18
|
+
| `?price.min=10&price.max=50` | `>=` and `<=` (aliases `gte`/`lte`; also `gt`/`lt`) |
|
|
19
|
+
| `?created_at=2024-01-01..2024-12-31` | range (`...` = exclusive end) |
|
|
20
|
+
| `?tags=red,blue,green` | `IN (…)` |
|
|
21
|
+
| `?name!=bob` | negate any filter (`!` suffix on the key) |
|
|
22
|
+
| `?name~=^wid` | regex match, case-insensitive (`~` suffix) |
|
|
23
|
+
| `?author=true` / `?author=false` | association exists / doesn't |
|
|
24
|
+
| `?line_items=3`, `?line_items.min=2` | association count |
|
|
25
|
+
| `?author.name~=bob` | filter through an association (nests recursively) |
|
|
26
|
+
| `?published=true` | call a model scope (zero-arity); with a value, the value is the argument |
|
|
27
|
+
| `?q=widget` | keyword search (see below) |
|
|
28
|
+
|
|
29
|
+
Datetime values parse with Chronic, so `?created_at.min=last%20week` works. Combine
|
|
30
|
+
freely; everything composes with `sort`, `q`, `view`, and `.csv`.
|
|
31
|
+
|
|
32
|
+
## Sorting
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
?sort=name ascending
|
|
36
|
+
?sort=-price descending
|
|
37
|
+
?sort=category,-price multiple
|
|
38
|
+
?sort=author.name through a belongs_to (joins automatically)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Keys are validated against real columns/associations — bad input is dropped, never
|
|
42
|
+
raw SQL. Table headers emit these links already.
|
|
43
|
+
|
|
44
|
+
## Keyword search
|
|
45
|
+
|
|
46
|
+
`?q=term` matches case-insensitively across the model's string/text columns
|
|
47
|
+
(parameter-filtered columns like passwords are skipped). A model overrides the
|
|
48
|
+
default by defining a `search` scope:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
scope :search, ->(term) { query("title~": term) }
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Programmatic: `Model.query`
|
|
55
|
+
|
|
56
|
+
The same engine, from Ruby — available on every model with no opt-in:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
Article.query("published" => true, "author.name~" => "bob")
|
|
60
|
+
Invoice.query("total.min" => 100)
|
|
61
|
+
Article.query(["draft term"]) # a bare string routes to search
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Returns a relation; chain as usual.
|
|
65
|
+
|
|
66
|
+
## CSV export
|
|
67
|
+
|
|
68
|
+
Every index responds to `.csv` (the toolbar has a Download CSV button). The export
|
|
69
|
+
carries the current filters + sort, spans the whole result set (not just the page),
|
|
70
|
+
mirrors the policy's displayable columns, and is capped at
|
|
71
|
+
`CafeCar.csv_export_row_limit` (default 10,000; truncation sets
|
|
72
|
+
`X-CafeCar-Truncated: true`).
|
|
73
|
+
|
|
74
|
+
## Chart view
|
|
75
|
+
|
|
76
|
+
Every index has a third view beside table and grid:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
?view=chart&chart_x=published_at&chart_by=month # count per month
|
|
80
|
+
?view=chart&chart_x=issued_on&chart_y=sum:total # or sum:/avg: of a numeric column
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Buckets `day`/`week`/`month`; columns are validated against the policy's
|
|
84
|
+
displayable date/numeric attributes. The chart aggregates the same filtered,
|
|
85
|
+
policy-scoped collection the table shows, as dependency-free inline SVG.
|
|
86
|
+
|
|
87
|
+
## Filter panel
|
|
88
|
+
|
|
89
|
+
The grammar above also drives a rendered panel: the model policy's
|
|
90
|
+
`permitted_filters` enumerates the controls (one typed per attribute), and the
|
|
91
|
+
same list is the query whitelist. List a dot-path to filter through an
|
|
92
|
+
association:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
def permitted_filters = %i[status client.status client.owner_id]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Each hop is the association name; the terminal may be the far column, its enum,
|
|
99
|
+
or a belongs_to (`client.owner_id` ≡ `client.owner`). The control is typed by
|
|
100
|
+
that terminal. An undeclared path — even one naming a real far column
|
|
101
|
+
(`?client.owner.email=`) — is pruned before any join, exactly like an
|
|
102
|
+
unpermitted top-level column.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Forms
|
|
2
|
+
|
|
3
|
+
Source: `lib/cafe_car/form_builder.rb`, `lib/cafe_car/field_builder.rb`,
|
|
4
|
+
`lib/cafe_car/field_info.rb`. CafeCar controllers set `CafeCar::FormBuilder` as the
|
|
5
|
+
default form builder, so plain `form_for`/`form_with` get all of this.
|
|
6
|
+
|
|
7
|
+
The default `_form` partial renders every field the policy permits — most form
|
|
8
|
+
changes are `permitted_attributes` changes (see [policies.md](policies.md)), not
|
|
9
|
+
view changes.
|
|
10
|
+
|
|
11
|
+
## Builder methods
|
|
12
|
+
|
|
13
|
+
```haml
|
|
14
|
+
= form_for object, url: href_for(object) do |f|
|
|
15
|
+
= f.field :name -# label + input + hint + error, wrapped in a Field
|
|
16
|
+
= f.field :price
|
|
17
|
+
= f.association :category -# searchable select (see below)
|
|
18
|
+
= f.remaining_fields -# every editable attribute not yet rendered
|
|
19
|
+
= f.submit
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- `f.field(method)` — the full labeled field. This is what the default form loops.
|
|
23
|
+
- `f.input(method, as: nil, **opts)` — just the input; `as:` forces a helper
|
|
24
|
+
(`as: :hidden_field`, `as: :text_area`, …).
|
|
25
|
+
- `f.association(method)` — a select for a `belongs_to`/`has_many`.
|
|
26
|
+
- `f.remaining_fields` — `policy.attributes.editable` minus fields already built;
|
|
27
|
+
keeps custom forms policy-driven instead of hand-listing everything.
|
|
28
|
+
- `f.hidden(:a, :b)`, `f.label`, `f.hint`, `f.error` as expected. Labels append `*`
|
|
29
|
+
for presence-validated fields; label/hint/placeholder text comes from the locale
|
|
30
|
+
(see [locales.md](locales.md)).
|
|
31
|
+
|
|
32
|
+
## Type inference
|
|
33
|
+
|
|
34
|
+
`FieldInfo#input` picks the input from the schema — string/decimal → text field,
|
|
35
|
+
text/json → textarea, integer → number, boolean → checkbox, date/datetime → typed
|
|
36
|
+
pickers, `has_secure_password` digests → password fields, ActiveStorage attachments
|
|
37
|
+
→ file field (`multiple` for `has_many_attached`), ActionText → rich text area,
|
|
38
|
+
`belongs_to`/`has_many` → association select, `accepts_nested_attributes_for` →
|
|
39
|
+
nested sub-forms.
|
|
40
|
+
|
|
41
|
+
## Association selects
|
|
42
|
+
|
|
43
|
+
`f.association :client` renders a select capped at `CafeCar.max_collection_options`
|
|
44
|
+
(default 100) options, enhanced with Tom Select typeahead that queries the model's
|
|
45
|
+
policy-scoped `GET /clients/options?q=…` feed — records past the cap stay reachable,
|
|
46
|
+
hidden rows never leak, and without JS it degrades to a plain select. Option labels
|
|
47
|
+
come from each record's presented `title`.
|
|
48
|
+
|
|
49
|
+
## Nested records
|
|
50
|
+
|
|
51
|
+
`accepts_nested_attributes_for :line_items, allow_destroy: true` plus the permit:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
def permitted_attributes
|
|
55
|
+
[ :number, :issued_on,
|
|
56
|
+
line_items_attributes: [ :id, :_destroy, *policy(LineItem).permitted_attributes ] ]
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The default form then renders add/remove-able line-item rows with no view code.
|
|
61
|
+
`f.fields_for :line_items` (no block) renders each sub-record's
|
|
62
|
+
`remaining_fields`.
|
|
63
|
+
|
|
64
|
+
## Overriding how a field type renders
|
|
65
|
+
|
|
66
|
+
`f.field` renders through a partial named after the field's type —
|
|
67
|
+
`_<type>_field.html.haml`, falling back to the generic `_field.html.haml`:
|
|
68
|
+
|
|
69
|
+
```haml
|
|
70
|
+
-# app/views/application/_string_field.html.haml (app-wide for all string fields)
|
|
71
|
+
-# app/views/admin/products/_string_field.html.haml (this resource only)
|
|
72
|
+
= field.wrapper do
|
|
73
|
+
= field.label
|
|
74
|
+
= field.input data: { controller: "autosize" }
|
|
75
|
+
= field.error
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The `field` local is a `FieldBuilder`: `field.label` / `field.input` / `field.hint`
|
|
79
|
+
/ `field.error` / `field.info` (the `FieldInfo`). Type names match the inference
|
|
80
|
+
above: `string`, `text`, `integer`, `boolean`, `date`, `datetime`, `password`,
|
|
81
|
+
`attachment`, `nested`, `belongs_to`, `has_many`, `json`.
|
|
82
|
+
|
|
83
|
+
(`lib/cafe_car/inputs/` exists in the gem but is dormant — the live path is
|
|
84
|
+
FieldBuilder + FieldInfo + these partials.)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Locales — all UI copy lives here
|
|
2
|
+
|
|
3
|
+
Source: `config/locales/en.yml` in the gem. Owner rule: **no hardcoded UI strings
|
|
4
|
+
in templates.** Every label, flash, confirm, hint, and button style resolves
|
|
5
|
+
through I18n; the gem ships defaults and a host overrides by defining the same key
|
|
6
|
+
in its own `config/locales/*.yml`.
|
|
7
|
+
|
|
8
|
+
When adding UI (a bulk action, a button in an overridden partial, a nav icon), add
|
|
9
|
+
its copy to the locale — don't inline the string.
|
|
10
|
+
|
|
11
|
+
## The key map
|
|
12
|
+
|
|
13
|
+
```yaml
|
|
14
|
+
en:
|
|
15
|
+
# Action labels (bulk-action buttons, controls): en.<action>
|
|
16
|
+
publish: Publish
|
|
17
|
+
destroy: Delete
|
|
18
|
+
|
|
19
|
+
# Bulk-action button styles — a Button flag per action name
|
|
20
|
+
bulk_actions:
|
|
21
|
+
styles:
|
|
22
|
+
destroy: danger # shipped default; add your own actions here
|
|
23
|
+
|
|
24
|
+
# Sidebar icons (Iconoir names), per controller
|
|
25
|
+
navigation:
|
|
26
|
+
icon:
|
|
27
|
+
products: box-iso
|
|
28
|
+
|
|
29
|
+
# Control links (show/edit/delete clusters): labels, confirms, disabled tooltips
|
|
30
|
+
controls:
|
|
31
|
+
confirm:
|
|
32
|
+
destroy: This %{Model} will be PERMANENTLY deleted. Are you sure?
|
|
33
|
+
disabled:
|
|
34
|
+
policy:
|
|
35
|
+
default: You don't have permission to %{action} this %{model}.
|
|
36
|
+
|
|
37
|
+
# Flash messages after CRUD actions
|
|
38
|
+
flashes:
|
|
39
|
+
create_html: "%{Model} created."
|
|
40
|
+
update_html: "%{Model} updated."
|
|
41
|
+
destroy_html: "%{Model} deleted."
|
|
42
|
+
|
|
43
|
+
# Dashboard metric tile labels (non-:all metrics)
|
|
44
|
+
metrics:
|
|
45
|
+
published: Published
|
|
46
|
+
|
|
47
|
+
# Per-field form copy: helpers.<kind>.<model>.<attribute>
|
|
48
|
+
helpers:
|
|
49
|
+
label: { article: { title: Headline } }
|
|
50
|
+
hint: { article: { summary: Shown on the index card. } }
|
|
51
|
+
placeholder: { article: { title: e.g. “Q3 results” } }
|
|
52
|
+
prompt: { invoice: { client_id: Pick a client… } }
|
|
53
|
+
|
|
54
|
+
# Model & attribute names — standard Rails
|
|
55
|
+
activerecord:
|
|
56
|
+
models:
|
|
57
|
+
article: Post
|
|
58
|
+
attributes:
|
|
59
|
+
article:
|
|
60
|
+
title: Headline
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Interpolations like `%{Model}`/`%{Models}`/`%{Action}` are provided (capitalized
|
|
64
|
+
and lowercase variants).
|
|
65
|
+
|
|
66
|
+
## Where they're read
|
|
67
|
+
|
|
68
|
+
- Bulk-action bar: label `en.<name>`, style `bulk_actions.styles.<name>`, confirm
|
|
69
|
+
`helpers.bulk_confirm`.
|
|
70
|
+
- Form fields: `helpers.label/hint/placeholder/autocomplete/prompt.<model>.<attr>`
|
|
71
|
+
(FieldInfo), falling back to `human_attribute_name`.
|
|
72
|
+
- Attribute/column headers everywhere: `activerecord.attributes`.
|
|
73
|
+
- Flashes: `flashes.<action>_html` via the presenter's `i18n`.
|
|
74
|
+
- Chart/dashboard strings: `chart.*`, `dashboard.*`.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Navigation and the dashboard
|
|
2
|
+
|
|
3
|
+
Source: `lib/cafe_car/navigation.rb`, `app/views/application/_navigation.html.haml`
|
|
4
|
+
and `_navigation_links.html.haml` (in the gem).
|
|
5
|
+
|
|
6
|
+
## The sidebar is route-driven — there is no menu registry
|
|
7
|
+
|
|
8
|
+
The sidebar lists every named `index` route in the app (Rails internals excluded),
|
|
9
|
+
grouped by namespace (`admin/…` routes render under an "Admin" heading). So:
|
|
10
|
+
|
|
11
|
+
- **Add a sidebar link:** add `resources :things` to the routes. That's it.
|
|
12
|
+
- **Remove one:** don't draw an index route (`resources :things, except: :index`
|
|
13
|
+
or `only:` what you need).
|
|
14
|
+
|
|
15
|
+
The current page's link highlights automatically (`current`/`ancestor` classes).
|
|
16
|
+
|
|
17
|
+
## Icons come from the locale
|
|
18
|
+
|
|
19
|
+
`navigation.icon.<controller_name>` maps a controller to an
|
|
20
|
+
[Iconoir](https://iconoir.com) icon name:
|
|
21
|
+
|
|
22
|
+
```yaml
|
|
23
|
+
en:
|
|
24
|
+
navigation:
|
|
25
|
+
icon:
|
|
26
|
+
products: box-iso
|
|
27
|
+
invoices: page-flip
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
No key, no icon — the link still renders.
|
|
31
|
+
|
|
32
|
+
## Customizing
|
|
33
|
+
|
|
34
|
+
Override the partials like any other ([views.md](views.md)):
|
|
35
|
+
|
|
36
|
+
- `app/views/application/_navigation.html.haml` — the whole sidebar (dashboard
|
|
37
|
+
link, route links, session link).
|
|
38
|
+
- `app/views/application/_navigation_links.html.haml` — just the grouped route
|
|
39
|
+
list; the `navigation` helper exposes `navigation.groups` / `navigation.routes`.
|
|
40
|
+
- A per-resource `_navigation.html.haml` changes (or, if empty, hides) the sidebar
|
|
41
|
+
for that resource's pages only.
|
|
42
|
+
|
|
43
|
+
## The dashboard — opt in by writing one view
|
|
44
|
+
|
|
45
|
+
No template, no dashboard: the route always exists, but it 404s and shows no nav
|
|
46
|
+
link until the host writes `app/views/cafe_car/dashboard/show.html.haml`:
|
|
47
|
+
|
|
48
|
+
```haml
|
|
49
|
+
- title "Dashboard"
|
|
50
|
+
|
|
51
|
+
= Page title: "Dashboard" do |page|
|
|
52
|
+
= page.Body do
|
|
53
|
+
.Dashboard
|
|
54
|
+
= metrics Article
|
|
55
|
+
= metric("Signups today") { User.where(created_at: Date.current.all_day).count }
|
|
56
|
+
= chart "New articles", model: Article, x: :created_at, by: :month
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- `metrics(Model)` — one count tile per scope named in the model policy's
|
|
60
|
+
`permitted_metrics` (`:all` = whole relation). Policy-driven; the default choice.
|
|
61
|
+
- `metric("Label") { … }` — one tile with whatever the block returns.
|
|
62
|
+
- `chart "Title", model:, x:, by:` — the inline-SVG bar chart, bucketing over the
|
|
63
|
+
`x` date column at `:day`/`:week`/`:month`. Column names are validated against
|
|
64
|
+
the policy's date columns — never raw SQL.
|
|
65
|
+
|
|
66
|
+
It's a plain view: add headings, your own partials, anything between tiles. Once
|
|
67
|
+
the file exists a Dashboard link appears at the top of the sidebar.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Policies — the source of truth
|
|
2
|
+
|
|
3
|
+
Source: `lib/cafe_car/policy.rb` and `app/policies/cafe_car/application_policy.rb`
|
|
4
|
+
in the gem. The host's `ApplicationPolicy` subclasses `CafeCar::ApplicationPolicy`;
|
|
5
|
+
each model gets a `<Model>Policy` (standard Pundit naming).
|
|
6
|
+
|
|
7
|
+
The policy declares, the UI renders. Before overriding a view, check whether the
|
|
8
|
+
change is really a policy change — usually it is.
|
|
9
|
+
|
|
10
|
+
## Action predicates
|
|
11
|
+
|
|
12
|
+
All default to `false` (deny). `new?` follows `create?`, `edit?` follows `update?`.
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
class ArticlePolicy < ApplicationPolicy
|
|
16
|
+
def index? = true
|
|
17
|
+
def show? = object.published? || edit?
|
|
18
|
+
def create? = true
|
|
19
|
+
def update? = user.admin? || object.author_id == user.id
|
|
20
|
+
def destroy? = !object.published?
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`object` is the record (or the class, on collection checks); `user` is the current
|
|
25
|
+
user. Predicates gate controller actions, and the default views hide/disable
|
|
26
|
+
controls the policy denies — a denied Edit link renders disabled with a
|
|
27
|
+
locale-driven tooltip, not hidden by hand.
|
|
28
|
+
|
|
29
|
+
## Visible rows: `Scope#resolve`
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
class Scope < Scope
|
|
33
|
+
def resolve = user.admin? ? scope.all : scope.where(owner: user)
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Every index, CSV export, chart, bulk action, and typeahead feed passes through this
|
|
38
|
+
— restricting here restricts everywhere. The base `Scope#resolve` raises until you
|
|
39
|
+
define it.
|
|
40
|
+
|
|
41
|
+
## Editable fields: `permitted_attributes`
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
def permitted_attributes = %i[name price description category_id]
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Drives both strong parameters *and* the default form (it renders exactly these
|
|
48
|
+
fields, in this order). Vary by record state or action:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
def permitted_attributes
|
|
52
|
+
if object.try(:new_record?) || me?
|
|
53
|
+
%i[name email avatar password password_confirmation]
|
|
54
|
+
else
|
|
55
|
+
%i[name email avatar]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def permitted_attributes_for_create = %i[name email owner_id] # per-action variant
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Nested records permit `<assoc>_attributes` the Rails way (with `:id` + `:_destroy`
|
|
63
|
+
for `allow_destroy`):
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
def permitted_attributes
|
|
67
|
+
[ :number, :issued_on, :paid,
|
|
68
|
+
line_items_attributes: [ :id, :_destroy, *policy(LineItem).permitted_attributes ] ]
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Displayed fields (derived — usually leave alone)
|
|
73
|
+
|
|
74
|
+
- `attributes.displayable` — what show pages/tables/JSON/CSV display: permitted
|
|
75
|
+
keys ∪ columns, with foreign keys folded into their associations and
|
|
76
|
+
Rails-parameter-filtered columns (passwords, tokens) removed.
|
|
77
|
+
- `attributes.editable` — what the form renders, derived from `permitted_attributes`.
|
|
78
|
+
- `title_attribute` — the record's display name; defaults to the first displayable
|
|
79
|
+
attribute. Override when wrong: `def title_attribute = :number`.
|
|
80
|
+
- `logo_attribute` — the attachment used as avatar/card image; defaults to the
|
|
81
|
+
first listable attachment.
|
|
82
|
+
|
|
83
|
+
## Bulk actions: `permitted_bulk_actions`
|
|
84
|
+
|
|
85
|
+
Default `%i[destroy]`. A custom action is three conventional pieces — no
|
|
86
|
+
registration anywhere:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# app/models/article.rb — the behavior
|
|
90
|
+
def publish! = update!(published_at: Time.zone.now)
|
|
91
|
+
|
|
92
|
+
# app/policies/article_policy.rb — the authorization + the list
|
|
93
|
+
def publish? = !object.published?
|
|
94
|
+
def permitted_bulk_actions = %i[publish destroy]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The index toolbar renders a button per listed action (label from the locale key
|
|
98
|
+
`en.publish`, style from `bulk_actions.styles.publish` — see
|
|
99
|
+
[locales.md](locales.md)). On submit, each selected record is checked against
|
|
100
|
+
`publish?` individually — unauthorized rows are skipped, never bulk-bypassed — then
|
|
101
|
+
receives `publish!`. Return `[]` to offer no bulk actions.
|
|
102
|
+
|
|
103
|
+
## Dashboard metrics: `permitted_metrics`
|
|
104
|
+
|
|
105
|
+
Default `[]`. Names of model scopes (`:all` = whole relation) rendered as count
|
|
106
|
+
tiles by the `metrics Article` dashboard helper:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
def permitted_metrics = %i[all published]
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
See [navigation.md](navigation.md) for the dashboard itself.
|