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
@@ -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.