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
|
@@ -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])
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
= Card :sticky do |card|
|
|
2
2
|
= filter_form_for objects do |f|
|
|
3
3
|
= card.Section do
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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) }
|
|
@@ -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:
|
|
7
|
-
= icon(:
|
|
6
|
+
= Button href: view_url(:chart), tip: "Chart View" do
|
|
7
|
+
= icon(:stats_report)
|
|
8
8
|
|
|
9
|
-
=
|
|
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,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[
|
|
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 @@
|
|
|
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
|
-
|
|
7
|
-
|
|
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.")
|
data/config/brakeman.ignore
CHANGED
|
@@ -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"
|
data/config/locales/en.yml
CHANGED
|
@@ -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
|
-
|
|
3
|
-
|
|
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
|
data/lib/cafe_car/attributes.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
+
# The custom actions this policy declares, grouped by target.
|
|
38
|
+
def actions = Actions.new(@policy)
|
|
19
39
|
|
|
20
|
-
|
|
21
|
-
|
|
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
|