| name | avo-dashboards-cards |
| description | Build Avo dashboards — grids of cards defined in `app/avo/dashboards/*.rb` — and the six card types they host (`app/avo/cards/*.rb`): metric, chartkick chart, partial, html, table, and list. Cards render on a dashboard page or on a resource's index/show/form to display aggregated data. Use when the user wants an analytics/overview/stats page, an admin home page with metrics, a chart of signups over time, revenue graphed by month, a pie/bar/line/area chart of X, a big number / KPI / metric, a total-users or count-of-active-records tile, a leaderboard or top-10 table, a list of latest sign-ups, a summary panel on a record's show page, an embedded map or iframe in the admin, auto-refreshing metrics on a wall display, a refresh button on a dashboard or on a single card, or caching a slow metric/chart query so the admin stops waiting on it. |
| allowed-tools | Read, Edit, Write, Glob, Grep, Bash, WebFetch |
| metadata | {"requires-gem":"avo-dashboards — paid add-on (https://avohq.io/addons/dashboards)"} |
Avo Dashboards & Cards
A dashboard is a page that lays out cards in a grid; a card is a self-contained block that shows aggregated data — a metric, a chart, a table, a list, or arbitrary content. Dashboards and cards are one purchased add-on: the dashboard is the host, the cards are the payload. The same card classes also render directly on a resource's Index/Show/form, so "put a chart on the Users page" and "build an overview dashboard" are the same building blocks.
- Dashboard file:
app/avo/dashboards/<name>.rb, class Avo::Dashboards::<Name> inheriting Avo::Dashboards::BaseDashboard. Cards are listed in its def cards.
- Card file:
app/avo/cards/<name>.rb, class Avo::Cards::<Name> inheriting one of the six base card classes (below).
- Parent: a card's host — a dashboard or a resource. The docs (and this skill) say "parent" for whichever it is.
- License: dashboards + cards are a paid add-on (the
avo-dashboards gem).
Docs — fetch on demand with WebFetch; prefer the raw .md (clean, no HTML):
Read the relevant guide before building anything non-trivial, and the reference page whenever you need the exact signature/default of an option.
Install
Dashboards are a paid add-on — the avo-dashboards gem. If bin/rails g avo:dashboard errors with an unknown generator, the add-on isn't installed — point the user at https://avohq.io/addons/dashboards and stop.
Charts additionally need chartkick, which Avo does not bundle. Add it before building any chartkick card:
gem "chartkick"
Optionally gem "groupdate", gem "hightop", gem "active_median" for time-series grouping helpers inside a chart's query.
Generators:
bin/rails g avo:dashboard sales
bin/rails g avo:card users_metric --type metric
bin/rails g avo:card signups --type chartkick
bin/rails g avo:card map_card --type partial
The generator only knows --type metric|chartkick|partial. HTML, table, and list cards (Avo 4.1+) have no generator — write the file by hand, mirroring the class/path convention.
When this applies
Map the request to a card type, then decide whether it lives on a dashboard or on a resource.
| Request | Card type |
|---|
| "Show total users", "a big number / KPI", "count of active records", "amount raised" | metric |
| "Chart signups over time", "graph revenue by month", "a pie/bar/line/area chart of X" | chartkick |
| "Embed a map / iframe", "drop in some custom HTML from a partial" | partial |
| "A small custom block", "render a component / a few rows of HTML without a partial file" | html |
| "A leaderboard", "top-10 products table", "latest failed jobs as a table" | table |
| "A list of latest sign-ups", "recent activity feed", "top 5 X" (no column headers) | list |
| "An analytics / overview / stats page", "admin home page with metrics" | a dashboard hosting several of the above |
| "Put these metrics on the Users index / a summary panel on the Order show page" | the same cards, placed on a resource (see placement) |
Workflow
1. Generate a dashboard (for a standalone page)
bin/rails g avo:dashboard sales
class Avo::Dashboards::Sales < Avo::Dashboards::BaseDashboard
self.id = "sales"
self.name = "Sales"
self.description = "Key metrics at a glance"
self.grid_cols = 4
self.global_ranges = [7, 30, 60, 365]
self.refresh_button = true
def cards
card Avo::Cards::UsersCount
card Avo::Cards::RevenueChart
divider label: "Details"
card Avo::Cards::LatestUsers
end
end
name/description accept a Proc (evaluated through Avo::ExecutionContext with the dashboard in scope) — handy for i18n. Gate the whole dashboard with self.visible (sidebar visibility) and/or self.authorize (access) — see Gotchas.
2. Build each card
Every card is a class in app/avo/cards/. Pick the base class for the type, set the base settings you need, and implement the type's method (query, body, or fields+query). Base settings shared by all card types:
self.id — required, unique within the parent (builds the card's Turbo frame).
self.label — the card title. self.description — subtitle under it. self.discreet_description — tiny info-icon tooltip for methodology notes.
self.cols / self.rows — grid span. cols is 1–6, rows is 1–12 (both default 1). On table/list cards rows also caps height (overflow scrolls).
self.display_header — false hides the label + range dropdown so content fills the card flush (maps, iframes). Boolean or Proc (card, parent, dashboard, resource in scope).
self.visible — Boolean or Proc (context, params, parent, dashboard, resource, card in scope).
self.refresh_every — a duration (e.g. 10.minutes); Avo reloads the card in the background.
self.refresh_button — true renders a manual refresh control on this card (default false).
self.cache_for — a duration; caches the card's query result for that long (see caching).
self.ranges + self.initial_range — a range dropdown in the header (see below).
A card can carry a manual refresh control, opted into per card with self.refresh_button = true (default false). It sits at the end of the header, or floats in the card's top corner when the card renders no header. It reloads only that card, keeping the viewer's selected range and any dashboard filters. Put it on cards where re-running the query means something — an HTML or partial card re-renders identical content, so a control there does nothing. A dashboard can add one control that refreshes all its cards with self.refresh_button = true (same name, same default, but independent — setting it on the dashboard does not turn on the cards' own controls, and the dashboard button reloads every card whether or not the cards carry one).
Most settings accept a Proc, evaluated through Avo::ExecutionContext with parent, resource, dashboard, card, arguments, and params available.
Ranges let the user re-query across time windows:
self.initial_range = 30
self.ranges = {
"7 days": 7, "30 days": 30, "60 days": 60, "365 days": 365,
Today: "TODAY", "Month to date": "MTD", "Quarter to date": "QTD",
"Year to date": "YTD", All: "ALL"
}
Integer entries mean "N days"; string entries ("TODAY", "MTD", "ALL", …) pass through untouched — your query reads range and interprets them. The hash is handed to Rails' options_for_select.
3. Register the cards on the parent
On a dashboard, list them in def cards (step 1). On a resource, see placement. At registration you can override the card's own attributes without editing the class — this is how you reuse one class with different labels/queries:
def cards
card Avo::Cards::UsersCount
card Avo::Cards::UsersCount,
label: "Active users",
description: "Active users only",
cols: 2,
rows: 2,
refresh_every: 2.minutes,
cache_for: 5.minutes,
visible: -> { current_user.admin? },
arguments: { active_users: true }
end
Overridable keys: label, description, discreet_description, cols, rows, refresh_every, cache_for, visible, chart_options, arguments. arguments is the clean way to parameterize one card class (encrypted in URLs) instead of duplicating it — read it in query:
def query
scope = User
scope = scope.active if arguments[:active_users].present?
result scope.count
end
Use divider label: "…" between cards to group them (or divider invisible: true for spacing with no line).
Card types
Metric card
Avo::Cards::MetricCard — a single big number. Compute it in query and hand it to result. Decorate with prefix/suffix, reshape with format.
class Avo::Cards::UsersCount < Avo::Cards::MetricCard
self.id = "users_count"
self.label = "Users count"
self.prefix = "$"
self.format = -> { number_to_social value, start_at: 1_000 }
def query
from = range.to_s.match?(/\A\d+\z/) ? range.to_i.days.ago : Time.at(0)
result User.where(created_at: from..).count
end
end
Inside query you have range, params, context, dashboard/resource/parent, and card. format runs with Rails number helpers mixed in (number_to_currency, number_to_social, …). Default format is -> { number_to_social value.to_i, start_at: 10_000 }.
Chartkick card
Avo::Cards::ChartkickCard — needs the chartkick gem. Set chart_type; return data from query/result.
class Avo::Cards::RevenueChart < Avo::Cards::ChartkickCard
self.id = "revenue_chart"
self.label = "Revenue by month"
self.chart_type = :area_chart
self.cols = 2
def query
result Order.group_by_month(:created_at).sum(:total)
end
end
Anything not covered by the built-in toggles goes through chart_options (a raw chartkick options Hash, or a Proc returning one — parent, arguments, result_data in scope). chart.js is the rendering backend.
Partial card
Avo::Cards::PartialCard — renders a partial. Best for embeds (maps, iframes) and one-off markup. Pair with display_header = false so the content sits flush.
class Avo::Cards::MapCard < Avo::Cards::PartialCard
self.id = "map_card"
self.partial = "avo/cards/map_card"
self.display_header = false
self.cols = 2
self.rows = 4
end
HTML card (4.1+)
Avo::Cards::HtmlCard — build the body from Ruby, no partial file. Implement body; every view helper (tag, safe_join, link_to, number_to_currency, render, main_app, …) is available on the card. The return value resolves by shape: a SafeBuffer (tag helpers / explicit render) passes through; a plain String is compiled as an inline ERB template (with a card local); anything else (a component instance, a {partial:, locals:} hash) is handed to render.
class Avo::Cards::NewestUsers < Avo::Cards::HtmlCard
self.id = "newest_users"
self.label = "Newest users"
def body
tag.ul class: "divide-y divide-neutral-100" do
safe_join(User.order(created_at: :desc).limit(5).map { |u|
tag.li(link_to(u.name, main_app.user_path(u)), class: "px-4 py-2")
})
end
end
end
Pass dynamic values through ERB tags or locals:, never by interpolating user input into the template string (see Gotchas).
Table card (4.1+)
Avo::Cards::TableCard — an index-style table fed by any query. Declare columns with fields (the same field DSL as resources) and return records from query/result.
class Avo::Cards::LatestUsers < Avo::Cards::TableCard
self.id = "latest_users"
self.label = "Latest users"
self.cols = 2
self.rows = 2
self.row_url = -> { record_path(record) }
def fields
field :name, as: :text, name: "User", link_to_record: true
field :email, as: :text, protocol: :mailto
field :active, as: :badge, name: "Status", options: { success: "Active" } do
record.active? ? "Active" : "Inactive"
end
end
def query
result User.order(created_at: :desc).limit(10)
end
Cells render through the resource's Index components, so every field type and option (computed blocks, format_using, link_to_record, badge options:) behaves exactly as on an index table. row_url may return a Hash ({url:, target: :_blank, tooltip:}) for a new tab / tooltip.
List card (4.1+)
Avo::Cards::ListCard — same fields+query shape as the table card, but renders a real <ul> with no column headers: the first field is each row's primary content, the rest trail at the end edge (badges, booleans, timestamps). Reach for it over a table when column headers would be noise.
class Avo::Cards::ActiveUsers < Avo::Cards::ListCard
self.id = "active_users"
self.label = "Active users"
self.row_url = -> { record_path(record) }
def fields
field :name, as: :text
field :active, as: :boolean
end
def query
result User.active.order(:name).limit(5)
end
end
row_url, density, empty_message, ranges, and the height cap all work the same as the table card.
Place cards on a resource
Cards aren't dashboard-only. On a resource, define one of these methods (same card/divider DSL as a dashboard). Avo resolves per view with this precedence — first defined wins:
| View | Specific | Fallback | Final |
|---|
| Index | index_cards | display_cards | cards |
| Show | show_cards | display_cards | cards |
| New | new_cards | form_cards | cards |
| Edit | edit_cards | form_cards | cards |
class Avo::Resources::Project < Avo::BaseResource
def show_cards
card Avo::Cards::AmountRaised
end
end
So cards is the catch-all, display_cards covers index+show, form_cards covers new+edit, and the view-specific method overrides both.
Cache an expensive query
When a card's query is slow — an aggregate over a big table, a call to an external service — give it self.cache_for. Avo stores the result for that duration through Avo.configuration.cache_store and skips the query entirely until it expires.
class Avo::Cards::UsersCount < Avo::Cards::MetricCard
self.id = "users_count"
self.cache_for = 5.minutes
def query
result User.where(active: true).count
end
end
The cache key is scoped to the current user and tenant, plus the card class, its parent and position, the selected range and the dashboard's global range, and (on a resource card) the view and record — so a query reading current_user is safe to cache. arguments is deliberately not part of the key; the card's position already separates two registrations of the same class. If your query varies on something the key doesn't cover, widen it by overriding cache_key:
def cache_key
super + [Current.account.id]
end
Clicking a card's refresh control (where refresh_button is on) bypasses the cache and rewrites the entry (only the clicker's, since the key is per user). refresh_every polling does not bypass it — pairing the two is how you poll a card often while querying rarely.
Gotchas
- chartkick is not bundled. A chartkick card with no
gem "chartkick" won't render. Add the gem first — check the Gemfile before building chart cards.
- HTML / table / list cards are Avo 4.1+ and have no generator.
bin/rails g avo:card only supports metric, chartkick, partial. Write 4.1 cards by hand and confirm the app is on 4.1+.
- Table/list records must have a registered Avo resource. Cells render through the model's resource; a model with no
Avo::Resources::* will error. Cross-check with the avo-resources skill.
- Table/list are top-N only. No pagination, no sorting — always cap
query with limit. For the full experience, send the user to that resource's Index instead.
refresh_every reloads the whole card, resetting the scroll position of a tall table/list. Prefer it on short cards or metrics/charts.
refresh_button is a class attribute, not a registration override. Set it in the card class; passing refresh_button: to card Avo::Cards::X, … raises ArgumentError — it isn't in the overridable keys.
cache_for does nothing on HTML and partial cards. They build their content at render time instead of running a query, so there's no result to store — wrap the markup in Rails' own cache block instead.
- Outside production the cache store is per-machine.
Avo.configuration.cache_store defaults to a file store under tmp/cache, so a multi-server staging environment caches separately on each box. Set config.cache_store in the Avo initializer to share it (see avo-performance).
- HTML card — never build the ERB template string from user input. Interpolated ERB values are auto-escaped, but a template assembled from user input is a server-side template injection. Pass dynamic values through ERB tags or
render inline: …, locals: {…}.
row_url ignores javascript: and anything that isn't http/https/mailto/relative — silently dropped.
Report
When done, tell the user:
- The dashboard file(s) created/edited (
app/avo/dashboards/<name>.rb) and each card file (app/avo/cards/<name>.rb) with its type.
- Where the cards render — which dashboard, or which resource + view method (
index_cards/show_cards/display_cards/form_cards/cards).
- Key settings applied per card (
cols/rows, ranges/initial_range, refresh_every, cache_for, prefix/suffix/format, chart_type, row_url) and any arguments/registration overrides, plus refresh_button wherever you turned it on — on a card, on the dashboard, or both.
- Follow-ups the user still owes: adding
gem "chartkick" (and running bundle), confirming Avo 4.1+ for html/table/list cards, registering an Avo resource for any model shown in a table/list card, i18n keys for global_ranges, and any visible/authorize policy wiring.