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
@@ -2,6 +2,12 @@
2
2
  - show = options.delete(:show) { false }
3
3
  - edit = options.delete(:edit) { true }
4
4
  - destroy = options.delete(:destroy) { true }
5
+ -# Custom member actions (Policy#permitted_member_actions) render per record on
6
+ -# the index; the show page lists them in its Actions card instead. Pass
7
+ -# `actions: false` (or an explicit list of names) to override.
8
+ - actions = options.delete(:actions) { action.index? }
9
+ - actions = policy(object).attributes.actions.member if actions == true
10
+ - actions = Array(actions.presence)
5
11
  - links = link(object)
6
12
 
7
13
  -# TODO: Handle resources with missing routes
@@ -10,3 +16,5 @@
10
16
  = c.Link links.show if show
11
17
  = c.Link links.edit if edit
12
18
  = c.Link links.destroy if destroy
19
+ - actions.each do |name|
20
+ = c.Link links.action(name)
@@ -0,0 +1,5 @@
1
+ -# Equality against one of the enum's declared values (`?status=archived`,
2
+ -# the enum key — QueryBuilder casts it natively; see Filter::FieldInfo#choices).
3
+ = Field do
4
+ = f.label info.method
5
+ = f.enum info.method, info.choices, include_blank: t(:any, scope: %i[helpers filter])
@@ -0,0 +1,4 @@
1
+ -# Generic fallback: a bare equality filter (`?<attr>=<value>`).
2
+ = Field do
3
+ = f.label info.method
4
+ = f.text_field info.method, placeholder: info.placeholder
@@ -1,8 +1,17 @@
1
1
  = Card :sticky do |card|
2
2
  = filter_form_for objects do |f|
3
3
  = card.Section do
4
- - f.remaining_attributes.each do |attr|
5
- = f.field attr
6
-
4
+ -# A GET form replaces the query string wholesale on submit, so the
5
+ -# composing control params (sort, view) ride along as hiddens; the keyword
6
+ -# search rides as its own visible field, one card with the filters.
7
+ - { sort: params[:sort], view: params[:view] }.compact_blank.each do |name, value|
8
+ = hidden_field_tag name, value, id: nil
9
+ = Field do
10
+ = f.label :q, t(:search, scope: %i[helpers filter])
11
+ = f.search_field :q, value: search_term, placeholder: t(:search, scope: %i[helpers filter])
12
+ - f.remaining_attributes.each do |attribute|
13
+ = f.filter attribute
14
+ - f.scopes.each do |scope|
15
+ = f.scope_toggle scope
7
16
  = card.Foot do
8
- = f.submit "Filter", commit: false
17
+ = f.submit t(:submit, scope: %i[helpers filter]), name: nil
@@ -0,0 +1,7 @@
1
+ -# "Has any of these associated records" — submits `?<assoc>.id[]=<id>`, which the
2
+ -# query DSL turns into a where_assoc_exists matching any of the chosen ids.
3
+ = Field do
4
+ = f.label info.method
5
+ = f.collection_select info.method, info.collection, :id, -> { present(_1).title },
6
+ { selected: f.object.value(info.method, :id) },
7
+ { multiple: true, name: "#{f.field_name(info.method, :id)}[]", **f.searchable_select(info) }
@@ -14,4 +14,5 @@
14
14
  = yield :head
15
15
 
16
16
  = stylesheet_link_tag "application", "data-turbo-track": "dynamic"
17
+ = theme_stylesheet_tag
17
18
  = javascript_importmap_tags
@@ -1,4 +1,6 @@
1
- = render "search"
1
+ .IndexToolbar
2
+ - if view.to_s == "table" && bulk_actions?
3
+ = render "bulk_actions"
2
4
 
3
5
  = render(view).presence or render("empty")
4
6
 
@@ -3,7 +3,16 @@
3
3
  = icon(:view_grid)
4
4
  = Button href: view_url(:table), tip: "Table View" do
5
5
  = icon(:table_rows)
6
- = Button href: csv_url, tip: "Download CSV" do
7
- = icon(:download)
6
+ = Button href: view_url(:chart), tip: "Chart View" do
7
+ = icon(:stats_report)
8
8
 
9
- = link(model.build).new(class: ui.Button(:primary).class_name, params: dot_params) { icon(:plus) + _1}
9
+ = Button href: csv_url, tip: "Download CSV" do
10
+ = icon(:download)
11
+
12
+ -# Custom collection actions (Policy#permitted_collection_actions — the policy
13
+ -# is the source of truth): one POST button per action, labeled + styled from
14
+ -# the locale (see Controller#collection_action).
15
+ - collection_actions.each do |name|
16
+ = collection_action name
17
+
18
+ = link(model.build).new(class: ui.Button(:primary).class_name, params: filter_params) { icon(:plus) + _1}
@@ -1,4 +1,9 @@
1
1
  = Navigation tag: :nav do |nav|
2
+ - if href = navigation.dashboard_href
3
+ = nav.Link(href:) do
4
+ = Icon t(:dashboard, scope: "navigation.icon").to_sym
5
+ Dashboard
6
+
2
7
  = render "navigation_links", nav:
3
8
 
4
9
  - if try(:authenticated?)
@@ -1,7 +1,7 @@
1
1
  - reflection = field.info.reflection
2
2
  - if reflection&.collection?
3
3
  - new_object = reflection.klass.new
4
- - nested_opts = field.form.model.nested_attributes_options[field.method] || {}
4
+ - nested_opts = field.form.model.nested_attributes_options[reflection.name] || {}
5
5
  - allow_destroy = nested_opts[:allow_destroy]
6
6
  - plural = reflection.klass.model_name.plural
7
7
  - item_partial = partial?("#{plural}/fields") ? "#{plural}/fields" : "fields"
@@ -0,0 +1,8 @@
1
+ -# Bounded range via the `.min`/`.max` dot-ops (`?price.min=10&price.max=50`).
2
+ -# Dates parse through Chronic on the query side, so a bare `2026-01-01` works.
3
+ - step = :any if info.type.in? %i[decimal float]
4
+ = Field do
5
+ = f.label info.method, for: "#{info.method}_min"
6
+ = Group do
7
+ = f.public_send info.input, info.method, name: f.field_name(info.method, :min), id: "#{info.method}_min", value: f.object.value(info.method, :min), placeholder: t(:min, scope: %i[helpers filter]), step: step
8
+ = f.public_send info.input, info.method, name: f.field_name(info.method, :max), id: "#{info.method}_max", value: f.object.value(info.method, :max), placeholder: t(:max, scope: %i[helpers filter]), step: step
@@ -0,0 +1,5 @@
1
+ -# Toggle for a policy-permitted model scope (`?published=true`).
2
+ = Field do
3
+ = f.label scope do
4
+ = f.check_box scope, { name: f.field_name(scope) }, "true", nil
5
+ = t(scope, scope: [ :helpers, :filter, :scopes, f.model.model_name.i18n_key ], default: scope.to_s.humanize)
@@ -0,0 +1,4 @@
1
+ -# Substring match — submits the `~` (contains) dot-op, e.g. `?name~=acme`.
2
+ = Field do
3
+ = f.label info.method
4
+ = f.text_field "#{info.method}~", id: info.method, placeholder: info.placeholder || t(:contains, scope: %i[helpers filter])
@@ -1,6 +1,8 @@
1
- = table_for objects do |t|
2
- = t.logo
3
- = t.title
4
- = t.remaining
5
- = t.timestamps
6
- = t.controls
1
+ = bulk_form do
2
+ = table_for objects do |t|
3
+ = t.select
4
+ = t.logo
5
+ = t.title
6
+ = t.remaining
7
+ = t.timestamps
8
+ = t.controls
@@ -0,0 +1 @@
1
+ = render "string_filter", f:, info:
@@ -1,13 +1,16 @@
1
1
  - title = title model_name.human(count: :all)
2
2
  - actions = render "index_actions"
3
3
  - body = render("index")
4
+ - filterable = policy(model.new).then { _1.attributes.filterable.any? or _1.permitted_scopes.any? }
4
5
 
5
6
  = Page title:, actions: do |page|
6
- -# = page.Aside do
7
- -# = render "filters"
7
+ - if filterable
8
+ = page.Aside do
9
+ = render "filters"
8
10
 
9
11
  = page.Body do
10
12
  = render "alerts"
13
+ = render "active_filters"
11
14
  = body
12
15
  = render "debug" if debug?
13
16
 
@@ -0,0 +1,11 @@
1
+ -# A dashboard chart tile: title + inline SVG, no filter controls. Rendered by the
2
+ -# `chart` helper. The x column is host-supplied but still runs through
3
+ -# ChartBuilder's date-column allowlist, so a bad column can never reach SQL raw.
4
+ - chart = chart_for(objects, column: x.to_s, bucket: by)
5
+
6
+ .Dashboard-tile.Dashboard-chart
7
+ %h4.Dashboard-chartTitle= title
8
+ - if chart.columns.include?(chart.column)
9
+ = chart
10
+ - else
11
+ .center= t("cafe_car.dashboard.no_date_column", default: "No date column to chart.")
@@ -0,0 +1,4 @@
1
+ -# A dashboard metric tile: a label over a value. Rendered by the `metric` helper.
2
+ .Dashboard-tile.Metric
3
+ .Metric-value= value
4
+ .Metric-label= label
@@ -1,29 +1,5 @@
1
1
  {
2
2
  "ignored_warnings": [
3
- {
4
- "warning_type": "Dangerous Eval",
5
- "warning_code": 13,
6
- "fingerprint": "9f7c27406a84219ad1d93b07b8c1acd435ad75921861a85a647e32f3d07d1289",
7
- "check_name": "Evaluation",
8
- "message": "Dynamic string evaluated as code",
9
- "file": "lib/cafe_car/auto_resolver.rb",
10
- "line": 16,
11
- "link": "https://brakemanscanner.org/docs/warning_types/dangerous_eval/",
12
- "code": "TOPLEVEL_BINDING.eval(\"class #{mod.name}::#{name} < CafeCar[:ApplicationController]\\n include CafeCar::Controller\\n cafe_car\\n self\\nend\\n\", \"lib/cafe_car/auto_resolver.rb\", 16)",
13
- "render_path": null,
14
- "location": {
15
- "type": "method",
16
- "class": "CafeCar::AutoResolver",
17
- "method": "define"
18
- },
19
- "user_input": null,
20
- "confidence": "Weak",
21
- "cwe_id": [
22
- 913,
23
- 95
24
- ],
25
- "note": ""
26
- },
27
3
  {
28
4
  "warning_type": "Dangerous Eval",
29
5
  "warning_code": 13,
@@ -47,30 +23,6 @@
47
23
  95
48
24
  ],
49
25
  "note": ""
50
- },
51
- {
52
- "warning_type": "Dangerous Eval",
53
- "warning_code": 13,
54
- "fingerprint": "f14f192898bdd59525b84317d91ecbbdc952f71e28dbeca457caf4c11bee0bc7",
55
- "check_name": "Evaluation",
56
- "message": "Dynamic string evaluated as code",
57
- "file": "lib/cafe_car/auto_resolver.rb",
58
- "line": 24,
59
- "link": "https://brakemanscanner.org/docs/warning_types/dangerous_eval/",
60
- "code": "TOPLEVEL_BINDING.eval(\"class #{mod.name}::#{name} < CafeCar[:ApplicationPolicy]\\n def admin? = Rails.env.development?\\n\\n def index? = admin?\\n def show? = admin?\\n def create? = admin?\\n def update? = admin?\\n def destroy? = admin?\\n\\n def permitted_attributes\\n model.info.fields.names.then do |names|\\n [*model.primary_key].reverse.map(&:to_sym) & names | names\\n end\\n end\\n\\n class Scope < Scope\\n def resolve = scope.all\\n end\\n self\\nend\\n\", \"lib/cafe_car/auto_resolver.rb\", 24)",
61
- "render_path": null,
62
- "location": {
63
- "type": "method",
64
- "class": "CafeCar::AutoResolver",
65
- "method": "define"
66
- },
67
- "user_input": null,
68
- "confidence": "Weak",
69
- "cwe_id": [
70
- 913,
71
- 95
72
- ],
73
- "note": ""
74
26
  }
75
27
  ],
76
28
  "brakeman_version": "8.0.4"
data/config/importmap.rb CHANGED
@@ -4,6 +4,7 @@ pin "cafe_car"
4
4
  pin "@hotwired/turbo-rails", to: "turbo.min.js"
5
5
  pin "@rails/actiontext", to: "actiontext.esm.js"
6
6
  pin "trix"
7
+ pin "tom-select", to: "tom-select.complete.min.js" # searchable association selects
7
8
 
8
9
  # pin "@hotwired/stimulus", to: "stimulus.min.js"
9
10
  # pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
@@ -10,12 +10,68 @@ en:
10
10
  navigation:
11
11
  icon:
12
12
  new_session: log-in
13
+ dashboard: dashboard
13
14
  sessions: log-in
14
15
  attachments: media-image-list
15
16
  blobs: media-image-folder
16
17
  passwords: key
17
18
  versions: multiple-pages
18
19
 
20
+ helpers:
21
+ bulk_confirm: "Apply “%{action}” to the selected rows?"
22
+ # A collection-action button's label — the action plus its reach (how many
23
+ # records the current filtered view holds), e.g. "Publish all 21". A
24
+ # count-keyed one/other hash so a host can reword either form; both default
25
+ # to the same "%{action} %{count}".
26
+ collection_action:
27
+ one: "%{action} %{count}"
28
+ other: "%{action} %{count}"
29
+ collection_confirm:
30
+ one: "Apply “%{action}” to %{count} %{models}?"
31
+ other: "Apply “%{action}” to %{count} %{models}?"
32
+
33
+ # The index filter panel (_filters + the _*_filter partials). Per-field
34
+ # copy nests per model under label/placeholder/hint (see
35
+ # Filter::FieldInfo#i18n) and scope toggles under scopes.<model>.<scope>.
36
+ filter:
37
+ title: Filters
38
+ search: Search
39
+ submit: Filter
40
+ any: Any
41
+ contains: Contains
42
+ min: Min
43
+ max: Max
44
+ # The active-filter chips (_active_filters): the row's accessible name, the
45
+ # per-chip remove control's label, and the clear-all link.
46
+ active: Active filters
47
+ remove: "Remove %{filter} filter"
48
+ clear: Clear all
49
+
50
+ # Button style (a Button component flag) per bulk action — a shipped default a
51
+ # host can extend/override. Unlisted actions fall back to the neutral style.
52
+ bulk_actions:
53
+ styles:
54
+ destroy: danger
55
+
56
+ # Button style per custom (member or collection) action — the same convention
57
+ # as bulk_actions.styles.
58
+ actions:
59
+ styles:
60
+ destroy: danger
61
+
62
+ dashboard:
63
+ no_date_column: No date column to chart.
64
+
65
+ # Index "chart" view controls. The y-metric labels interpolate the chartable
66
+ # column's own attribute name (e.g. "Sum of Total").
67
+ chart:
68
+ no_columns: No date columns to chart.
69
+ update: Update
70
+ metric:
71
+ count: Count
72
+ sum: "Sum of %{attribute}"
73
+ avg: "Average %{attribute}"
74
+
19
75
  list_html:
20
76
  zero: "(none)"
21
77
  one: "%{list}"
@@ -51,6 +107,10 @@ en:
51
107
  create_html: "%{Model} created."
52
108
  update_html: "%{Model} updated."
53
109
  destroy_html: "%{Model} deleted."
110
+ # Custom-action notices: %{action} is the action's label (en.<name>),
111
+ # e.g. "Publish article" / "Publish all articles".
112
+ member_action: "%{action} %{model}"
113
+ collection_action: "%{action} %{models}"
54
114
 
55
115
  activerecord:
56
116
  models:
data/config/routes.rb CHANGED
@@ -1,9 +1,22 @@
1
1
  CafeCar::Engine.routes.draw do
2
- scope module: :cafe_car, as: :cafe_car do
3
- get "components", to: "examples#index"
2
+ # The `components` gallery is an unauthenticated UI demo, mounted for development
3
+ # only so a host app never inherits a public, policy-skipping route in production.
4
+ if Rails.env.development?
5
+ scope module: :cafe_car, as: :cafe_car do
6
+ get "components", to: "examples#index"
7
+ end
4
8
  end
5
9
 
6
10
  # Opt-in login/logout. Singular resource so the form posts to /session and
7
11
  # request_authentication can redirect to new_session_path.
8
12
  resource :session, only: %i[new create destroy], controller: "cafe_car/sessions"
13
+
14
+ # The dashboard overview is opt-in via a host template, not config: the route
15
+ # always mounts, but the controller 404s unless the host has written
16
+ # `app/views/cafe_car/dashboard/show.html.haml`. So a CRUD-only host that never
17
+ # writes the template sees no dashboard (404 on direct hit, no nav link), while
18
+ # opt-in stays a matter of "drop a view" — the same convention as every other
19
+ # CafeCar surface. (Routes are drawn at boot, before any view context, so the
20
+ # template check can't gate the mount itself.)
21
+ get "dashboard", to: "cafe_car/dashboards#show", as: :dashboard
9
22
  end
@@ -1,23 +1,51 @@
1
+ # The policy's attribute sets — the single surface for "which attributes does
2
+ # this policy expose, and how." Mirrors the nested Scope: a policy builds one
3
+ # (`policy.attributes`) and reads `.listable`, `.displayable`, `.editable`,
4
+ # `.filterable`, and `.actions` through it. Every reader derives from the
5
+ # policy's host-overridable declarations (`permitted_attributes`,
6
+ # `permitted_filters`, `permitted_*_actions`), so a host override of any of
7
+ # those flows through unchanged.
1
8
  class CafeCar::Attributes
2
- attr_reader :user, :object, :permitted
3
-
4
- def initialize(user, object, permitted_attributes)
5
- @user = user
6
- @object = object
7
- @permitted = [ *permitted_attributes ]
8
- process_attributes!
9
+ def initialize(policy)
10
+ @policy = policy
9
11
  end
10
12
 
11
- def info(method) = CafeCar[:FieldInfo].new(object:, method:)
13
+ # Columns an index table shows by default — the model's listable fields.
14
+ def listable = model.info.fields.listable.map(&:method)
15
+
16
+ # Columns/associations shown on a record and the default filter set: the
17
+ # permitted attribute keys unioned with the model's own columns, each foreign
18
+ # key resolved to its association, parameter-filtered keys (password/token)
19
+ # and `id` dropped.
20
+ def displayable
21
+ permitted_attribute_keys
22
+ .union(model.columns.map(&:name).map(&:to_sym))
23
+ .map { association_for_attribute(_1) || _1 }
24
+ .reject { filtered_attribute? _1 } - %i[id]
25
+ end
12
26
 
27
+ # Form input keys for the permitted attributes, minus keys a richer input
28
+ # abrogates (a file field replaces its `*_cache`, a nested field its ids).
13
29
  def editable
14
- @editable ||= @permitted.map()
30
+ permitted_fields.map(&:input_key) - permitted_fields.flat_map(&:abrogated_keys)
15
31
  end
16
32
 
33
+ # The attributes an index may be filtered by — the policy's `permitted_filters`
34
+ # (which defaults to #displayable). Host-overridable on the policy.
35
+ def filterable = @policy.permitted_filters
17
36
 
18
- private
37
+ # The custom actions this policy declares, grouped by target.
38
+ def actions = Actions.new(@policy)
19
39
 
20
- def process_attributes!
21
- @editable = @permitted.clone
40
+ # Reads the policy's three host-overridable action lists through one object.
41
+ Actions = Struct.new(:policy) do
42
+ def member = policy.permitted_member_actions
43
+ def collection = policy.permitted_collection_actions
44
+ def bulk = policy.permitted_bulk_actions
22
45
  end
46
+
47
+ private
48
+
49
+ delegate :model, :permitted_attribute_keys, :permitted_fields,
50
+ :association_for_attribute, :filtered_attribute?, to: :@policy
23
51
  end
@@ -0,0 +1,217 @@
1
+ module CafeCar
2
+ # Aggregates an index collection into time buckets for the index "chart" view.
3
+ # Given a datetime/date column (the x-axis) and a granularity it GROUP BYs a
4
+ # DB-portable truncation of that column and COUNTs each bucket, then renders an
5
+ # inline SVG bar chart. The collection passed in is already policy-scoped and
6
+ # filtered (it is the same relation the table view renders), so the chart never
7
+ # counts rows the user can't see and always honors the active filters.
8
+ class ChartBuilder
9
+ # strftime patterns, shared by SQLite's `strftime` and Ruby's `Time#strftime`
10
+ # so a bucket label is identical whichever adapter produced the key.
11
+ FORMATS = { day: "%Y-%m-%d", week: "%Y-%W", month: "%Y-%m" }.freeze
12
+ DEFAULT_BUCKET = :month
13
+
14
+ # The y-axis aggregations. `count` is a plain row count (no column); `sum`/`avg`
15
+ # aggregate a numeric column and map to the portable ActiveRecord calculation.
16
+ OPERATIONS = { "sum" => :sum, "avg" => :average }.freeze
17
+ NUMERIC_TYPES = %i[integer decimal float].freeze
18
+ DEFAULT_METRIC = "count"
19
+ METRIC_DEFAULTS = { "sum" => "Sum of %{attribute}", "avg" => "Average %{attribute}" }.freeze
20
+
21
+ # SVG geometry (user units; the viewBox scales it to fit its container). WIDTH
22
+ # and HEIGHT are FIXED so the chart keeps one landscape shape at ANY bucket
23
+ # count. The CSS gives `.Chart` a full-width block with `height: auto`, so the
24
+ # rendered height follows the viewBox aspect ratio — a column-per-bucket layout
25
+ # made that viewBox near-square for a few buckets (rendered tall and narrow) and
26
+ # very wide for many (rendered flat and short). Distributing the bars across a
27
+ # fixed WIDTH instead holds a steady ~3.5:1 landscape that always fills the column.
28
+ WIDTH = 1000
29
+ HEIGHT = 280
30
+ PAD_TOP = 14 # room for the value label above the tallest bar
31
+ PAD_BOTTOM = 22 # room for the x-axis (bucket) labels
32
+ MAX_BAR = 72 # cap bar width so a handful of buckets don't balloon
33
+ BAR_RATIO = 0.6 # bar width as a fraction of its slot
34
+
35
+ delegate :tag, :safe_join, :t, to: :@template
36
+
37
+ def initialize(template, objects:, column: nil, bucket: nil, metric: nil)
38
+ @template = template
39
+ @objects = objects
40
+ @column = pick_column(column)
41
+ @bucket = FORMATS.key?(bucket.to_s.to_sym) ? bucket.to_s.to_sym : DEFAULT_BUCKET
42
+ @metric = pick_metric(metric)
43
+ end
44
+
45
+ attr_reader :column, :bucket, :metric
46
+
47
+ # The date/datetime columns offered as x-axis choices: the model's displayable
48
+ # attributes (policy-respecting) whose type is a date. Column NAMES only reach
49
+ # the query through this allowlist — a `?chart_x=` param outside it is dropped
50
+ # by #pick_column, so a raw param can never be interpolated as a column name.
51
+ def columns
52
+ @columns ||= policy.attributes.displayable
53
+ .map { info(_1) }
54
+ .select { _1.type.in?(%i[date datetime]) }
55
+ .map { _1.method.to_s }
56
+ end
57
+
58
+ # The numeric columns offered as y-axis aggregates: the model's displayable
59
+ # attributes (policy-respecting, the SAME source of truth as the x-axis) whose
60
+ # type is numeric. A `chart_y=sum:<col>` param's column is validated against
61
+ # this list by #pick_metric, so a raw param never becomes a column reference.
62
+ def value_columns
63
+ @value_columns ||= policy.attributes.displayable
64
+ .map { info(_1) }
65
+ .select { _1.type.in?(NUMERIC_TYPES) }
66
+ .map { _1.method.to_s }
67
+ end
68
+
69
+ def column_options = columns.map { [ info(_1).label, _1 ] }
70
+ def bucket_options = FORMATS.keys.map { [ _1.to_s.capitalize, _1.to_s ] }
71
+
72
+ # Whether a y-metric selector is worth showing — only when there's a numeric
73
+ # column to aggregate (otherwise `count` is the only choice).
74
+ def metrics? = value_columns.any?
75
+
76
+ # `[label, encoding]` pairs for the y-metric select: a plain count plus a
77
+ # sum + average per numeric column. Labels come from the locale.
78
+ def metric_options
79
+ count = [ t("chart.metric.count", default: "Count"), DEFAULT_METRIC ]
80
+ aggs = value_columns.flat_map do |col|
81
+ attribute = info(col).label
82
+ OPERATIONS.each_key.map { |op| [ metric_label(op, attribute), "#{op}:#{col}" ] }
83
+ end
84
+ [ count, *aggs ]
85
+ end
86
+
87
+ def metric_label(op, attribute)
88
+ t("chart.metric.#{op}", attribute:, default: METRIC_DEFAULTS.fetch(op) % { attribute: })
89
+ end
90
+
91
+ # Ordered `{ "2026-01" => count }` over the policy-scoped, filtered collection.
92
+ def data = @data ||= aggregate
93
+
94
+ def html_safe? = true
95
+ def to_s = svg.to_s
96
+ def ~@ = @template.concat(to_s)
97
+
98
+ private
99
+
100
+ def aggregate
101
+ return {} unless @column
102
+ base.group(bucket_node).public_send(operation, *value_column)
103
+ .reject { |bucket, _| bucket.nil? } # rows with a NULL x-axis value
104
+ .transform_keys { label_for _1 }
105
+ .transform_values { value_for _1 }
106
+ .sort.to_h
107
+ end
108
+
109
+ # The ActiveRecord calculation for the chosen metric — `:count` (no column),
110
+ # else `:sum`/`:average` over #value_column. All three emit portable SQL
111
+ # (COUNT(*) / SUM(col) / AVG(col)) that runs on both Postgres and SQLite.
112
+ def operation = OPERATIONS.fetch(@metric.split(":").first, :count)
113
+ def value_column = @metric.split(":", 2)[1]&.to_sym
114
+
115
+ # Normalize an aggregate for the label and bar scale: counts and integer sums
116
+ # stay Integer; a decimal sum or average collapses to Integer when whole
117
+ # (301.0 -> 301) and otherwise rounds to 2 places (avg 33.333 -> 33.33), so a
118
+ # BigDecimal never renders as "0.301e3".
119
+ def value_for(raw)
120
+ n = raw.is_a?(BigDecimal) ? raw.to_f : raw
121
+ return n unless n.is_a?(Float)
122
+ n == n.round ? n.round : n.round(2)
123
+ end
124
+
125
+ # The y-metric encoding: `count` (default), or `sum:<col>`/`avg:<col>` over a
126
+ # numeric column. The column half is validated against #value_columns — the
127
+ # same policy-driven allowlist as the x-axis — so a `?chart_y=` param can never
128
+ # put a raw column name into the aggregate. An unknown or non-numeric column
129
+ # (or a bare `count`) falls back to a plain record count.
130
+ def pick_metric(param)
131
+ op, col = param.to_s.split(":", 2)
132
+ return DEFAULT_METRIC unless OPERATIONS.key?(op) && value_columns.include?(col)
133
+ "#{op}:#{col}"
134
+ end
135
+
136
+ # The collection stripped of clauses that would corrupt a GROUP BY aggregate:
137
+ # pagination (else only the current page counts), ordering (a non-grouped ORDER
138
+ # BY column is invalid under GROUP BY on Postgres), and eager loading (its LEFT
139
+ # JOINs could multiply counts). The WHERE — filters + policy scope — is kept.
140
+ def base
141
+ @objects.except(:limit, :offset, :includes, :eager_load, :preload).reorder(nil)
142
+ end
143
+
144
+ # DB-portable date truncation as an Arel function (no raw SQL string, so no
145
+ # injection surface and Brakeman-clean). The granularity is a fixed keyword and
146
+ # the column is allowlisted, so neither is attacker-controlled.
147
+ def bucket_node
148
+ col = @objects.arel_table[@column]
149
+ if postgres?
150
+ Arel::Nodes::NamedFunction.new("date_trunc", [ Arel::Nodes.build_quoted(@bucket.to_s), col ])
151
+ else
152
+ Arel::Nodes::NamedFunction.new("strftime", [ Arel::Nodes.build_quoted(FORMATS[@bucket]), col ])
153
+ end
154
+ end
155
+
156
+ # SQLite's strftime already returns the formatted String; Postgres' date_trunc
157
+ # returns a Time we format the same way, so both adapters key by the same label.
158
+ def label_for(key) = key.is_a?(String) ? key : key.to_time.strftime(FORMATS[@bucket])
159
+
160
+ def postgres? = @objects.klass.connection_db_config.adapter.match?(/postg/i)
161
+
162
+ def pick_column(param)
163
+ param = param.to_s
164
+ return param if columns.include?(param)
165
+ columns.include?("created_at") ? "created_at" : columns.first
166
+ end
167
+
168
+ # Build the policy from a model instance, not the relation — the same way the
169
+ # rest of CafeCar does (`policy(model.new)`). A policy's `permitted_attributes`
170
+ # may inspect the record (e.g. `object.new_record?`), which a relation can't answer.
171
+ def policy = @template.policy(@objects.klass.new)
172
+ def info(name) = @objects.info.field(name)
173
+
174
+ # --- SVG rendering ------------------------------------------------------
175
+
176
+ def svg
177
+ points = data
178
+ max = points.values.max || 0
179
+ plot = HEIGHT - PAD_TOP - PAD_BOTTOM
180
+ slot = points.empty? ? WIDTH : WIDTH.to_f / points.size
181
+ bar_w = [ slot * BAR_RATIO, MAX_BAR ].min
182
+
183
+ tag.svg(class: "Chart", role: "img", "aria-label": aria_label,
184
+ "viewBox": "0 0 #{WIDTH} #{HEIGHT}", preserveAspectRatio: "xMidYMid meet") do
185
+ safe_join [ baseline, *bars(points, max, plot, slot, bar_w) ]
186
+ end
187
+ end
188
+
189
+ def baseline
190
+ y = HEIGHT - PAD_BOTTOM
191
+ tag.line(class: "Chart-axis", x1: 0, y1: y, x2: WIDTH, y2: y,
192
+ stroke: "currentColor", "stroke-opacity": "0.25")
193
+ end
194
+
195
+ def bars(points, max, plot, slot, bar_w)
196
+ points.each_with_index.map do |(label, value), i|
197
+ cx = slot * (i + 0.5)
198
+ x = cx - bar_w / 2
199
+ h = max.zero? ? 0 : (value.to_f / max * plot).round
200
+ y = HEIGHT - PAD_BOTTOM - h
201
+
202
+ tag.g(class: "Chart-bar", "data-bucket": label, "data-value": value) do
203
+ safe_join [
204
+ tag.title("#{label}: #{value}"),
205
+ tag.rect(x:, y:, width: bar_w, height: h, rx: 2, fill: "currentColor"),
206
+ tag.text(value, x: cx, y: y - 4, class: "Chart-value", "text-anchor": "middle"),
207
+ tag.text(label, x: cx, y: HEIGHT - PAD_BOTTOM + 15, class: "Chart-label", "text-anchor": "middle")
208
+ ]
209
+ end
210
+ end
211
+ end
212
+
213
+ def aria_label
214
+ "#{@objects.model_name.human(count: :all)} by #{info(@column).label}" if @column
215
+ end
216
+ end
217
+ end