Cards
Cards are one way of quickly adding custom content for your users.
Cards can be used on dashboards or resources, we'll refer to both of them as "parent" since they're hosting the cards.
You can add six types of cards to your parent: partial, html, metric, chartkick, table, and list.
Base settings
All cards have some standard settings like id, which must be unique, label, description, and discreet_description. The label is the title of your card, the description is a subtitle rendered below the title, and the discreet_description shows a tiny info icon at the bottom-right of the card with a tooltip containing the text.
Each card has its own cols and rows settings to control the width and height of the card inside the parent's grid. cols takes values from 1 to 6; rows takes values from 1 to 12.
On table and list cards rows is a maximum rather than a fixed height — those cards size to the records they hold and scroll once they reach the step.
All these settings can be set to a lambda.
The lambda will be executed using Avo::ExecutionContext. Within this blocks, you gain access to all attributes of Avo::ExecutionContext along with the parent, resource, dashboard and card.
class Avo::Cards::UsersMetric < Avo::Cards::MetricCard
self.id = "users_metric"
self.label = -> { "Users count" }
self.description = -> { "Users description" }
self.discreet_description = -> { "How this number is calculated" }
self.cols = 1
self.rows = 1
self.display_header = true
end
description
Renders directly under the card's title as a subtitle. Use it when the extra context is always relevant and should be visible at a glance.
class Avo::Cards::UsersMetric < Avo::Cards::MetricCard
self.label = "Users count"
self.description = "Across all teams and workspaces"
enddiscreet_description
Renders a small info icon in the bottom-right corner of the card. Hovering it shows a tooltip with the text. Use it for secondary context that would otherwise clutter the card — methodology notes, data source disclaimers, definitions, etc.
class Avo::Cards::UsersMetric < Avo::Cards::MetricCard
self.label = "Users count"
self.discreet_description = "Counts only active, non-deleted users created in the selected range."
endBoth options accept a lambda evaluated through Avo::ExecutionContext, with access to parent, resource, dashboard, card, arguments and params:
class Avo::Cards::UsersMetric < Avo::Cards::MetricCard
self.discreet_description = -> { "Computed at #{Time.current.to_fs(:short)}" }
endYou can also override either option per-registration on the parent dashboard or resource:
class Avo::Dashboards::Dashy < Avo::Dashboards::BaseDashboard
def cards
card Avo::Cards::UsersMetric,
description: "Signups this week",
discreet_description: "Includes invited users who have not yet confirmed."
end
endRanges
Control the aggregation using ranges
You may also want to give the user the ability to query data in different ranges. You can control what's passed in the dropdown using the ranges attribute. The array passed here will be parsed and displayed on the card. All integers are transformed to days, and other string variables will be passed as they are.
You can also set a default range using the initial_range attribute.
The parameter you pass to the range option will be directly passed to the options_for_select helper, so it behaves more like a regular select_tag.
class Avo::Cards::UsersMetric < Avo::Cards::MetricCard
self.id = 'users_metric'
self.label = 'Users count'
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"
}
endKeep the data fresh
If the parent is something that you keep on the big screen, you need to keep the data fresh at all times. That's easy using refresh_every. You pass the number of seconds you need to be refreshed and forget about it. Avo will do it for you.
class Avo::Cards::UsersMetric < Avo::Cards::MetricCard
self.id = 'users_metric'
self.refresh_every = 10.minutes
endRefresh a card on demand
Sometimes an interval is the wrong tool — someone runs an action, comes back, and wants that one number brought up to date now. A card can carry a refresh control for exactly that. Turn it on with refresh_button:
class Avo::Cards::UsersMetric < Avo::Cards::MetricCard
self.id = 'users_metric'
self.refresh_button = true
endIt's off by default, matching the dashboard-wide button. Put it on the cards where re-running the query means something — an HTML or partial card re-renders identical content, so a control there does nothing.
The control sits at the end of the card header, next to the ranges dropdown. On a card that renders no header at all — one with no label, description, or ranges, or one that hides its header — it floats in the card's top corner instead, so the card keeps its original height.
A manual refresh re-runs only that card's query and swaps that card in place — the rest of the page is untouched. It keeps whatever range the viewer has selected rather than snapping back to initial_range, and preserves any dashboard filters that are applied.
On a card that also sets refresh_every, refreshing by hand restarts that countdown. On one that sets cache_for, it bypasses the cache and re-runs the query.
Cache an expensive query
When a card's query is slow — an aggregate over a large table, a call to an external service — you rarely need it recomputed on every page load. Give it cache_for and Avo stores the result for that long, skipping the query entirely until it expires.
class Avo::Cards::UsersMetric < Avo::Cards::MetricCard
self.id = 'users_metric'
self.cache_for = 5.minutes
def query
result User.where(active: true).count
end
endEntries are scoped to the current user, tenant and locale, so a card querying current_user — or returning translated content — is safe to cache. Each range is cached separately too, and on a resource card each record gets its own entry. Upgrading Avo or a plugin busts every entry as well, since the installed versions are part of the key. If your query varies on something else, override cache_key as shown in the reference.
Someone clicking the card's refresh control is asking for current data, so that click re-runs the query and rewrites the entry — but only their own, since the key is per user. Automatic refresh_every polling still respects cache_for, which is what makes the two worth pairing: poll often, query rarely.
WARNING
Partial and HTML cards build their content at render time instead of running a query, so cache_for does nothing for them. Wrap the markup in Rails' own cache block instead.
Load cards only when they're needed
Sinceavo-dashboards >= v4.3.4Every card loads in its own request after the page renders, so a slow card never holds up the page. A card also waits until it scrolls into view: a card further down a long dashboard runs its query only when the viewer reaches it.
If a card is too expensive to run on every visit, even when it's visible, set loading to :manual, the same value tabs and association fields take. The card renders its title, description, and a load button instead of its content, and runs its query only when someone clicks it.
# app/avo/cards/revenue_breakdown.rb
class Avo::Cards::RevenueBreakdown < Avo::Cards::ChartkickCard
self.id = 'revenue_breakdown'
self.loading = :manual
endOnce loaded, the card behaves like any other card: its range select, refresh control, and refresh_every polling all work. Refreshing the whole dashboard skips cards that weren't loaded yet.
Localization
The shortest path is a locale key. Avo resolves avo.card_translations.<class_path>.{label,description,discreet_description} before falling back to the class attribute, the same way it does for resources and actions:
# config/locales/avo.sv.yml
sv:
avo:
card_translations:
users_metric:
label: Antal användare
description: Över alla team# app/avo/cards/users_metric.rb
class Avo::Cards::UsersMetric < Avo::Cards::MetricCard
self.id = "users_metric"
self.label = "Users count" # the fallback when no translation is present
# Optional. Defaults to avo.card_translations.users_metric
# self.translation_key = "avo.card_translations.users_metric"
endThe key is derived from the class path, not from self.id — two registrations of one card class share an id, and a card placed on a resource has no dashboard to scope an id against. A namespaced card uses the slash-joined path: Avo::Cards::Sales::Monthly resolves to avo.card_translations.sales/monthly.
A label set at registration wins over the key
card Avo::Cards::UsersMetric, label: "Active users" is more specific than a per-class translation, so it wins — otherwise a dashboard registering the same card twice would collapse both to one string. Pass a lambda if that override also needs translating.
A dashboard's own name and description work the same way, under avo.dashboard_translations.*.
Or assign a callable
The class attribute stays available and still resolves per request, which is what you want when the title depends on something the locale file cannot know:
A card's label, description and discreet_description all accept a callable, and it is resolved on every render in the requesting user's locale. That is all it takes to translate a card's chrome: assign a lambda that calls I18n.t.
# app/avo/cards/users_metric.rb
class Avo::Cards::UsersMetric < Avo::Cards::MetricCard
self.id = "users_metric"
self.label = -> { I18n.t("avo.cards.users_metric.label", default: "Users count") }
self.description = -> { I18n.t("avo.cards.users_metric.description", default: "Across all teams") }
self.discreet_description = -> { I18n.t("avo.cards.users_metric.discreet_description", default: "Active users only") }
def query
result User.where(active: true).count
end
end# config/locales/avo.cards.sv.yml
sv:
avo:
cards:
users_metric:
label: Antal användare
description: Över alla team
discreet_description: Endast aktiva användareThe key paths are spelled in full above on purpose: the lookups are avo.cards.users_metric.label, avo.cards.users_metric.description and avo.cards.users_metric.discreet_description.
A dashboard's name and description work the same way.
The namespace is yours
Pick a key namespace your app owns. avo.cards.* reads well beside Avo's own keys, though note the Dashboards gem ships its own chrome strings under avo.cards.* too — see the table in Localization. Keys nested under your card's id, as above, cannot collide with them.
Always pass a String default:
Under a locale your app has not translated, a lookup with no default: renders Translation missing: … as the card title, and Avo does not fall back to English for you unless you have configured config.i18n.fallbacks.
A Symbol is not resolved as an i18n key. self.label = :users_count sets the literal label :users_count — use a callable.
It holds on the refresh path too
A card reloading through its own Turbo frame — whether from the refresh control or refresh_every polling — goes through Avo's normal controller stack, which wraps every request in the reader's locale. So a refreshed card comes back translated, not in the server's default language.
The cost of a callable
Because it re-resolves per request — which is exactly what makes it follow the locale — anything expensive inside label runs on every render. An I18n.t lookup is cheap. A query is not: that is what query is for.
Translating the gem's own chrome
Separately from your card titles, the Dashboards gem renders a few strings of its own — the refresh controls, the empty state, the editor links. Those resolve under avo.cards.*, and the gem ships English only. Copy this tree, rename the root key to your locale, and translate the values:
# config/locales/avo.cards.sv.yml
sv:
avo:
cards:
refresh: Refresh card
refresh_labeled: Refresh %{label}
load: Load card
dashboard:
learn_more: Learn more
refresh: Refresh all cardsrefresh_labeled names the card being refreshed and is used when the card has a label; refresh is the fallback for a card without one. load is the Load button of a manual card without a label; with one, the button uses Avo's own avo.load_item, already translated in every locale Avo ships.
Keep the %{label} placeholder. Dropping it is harmless — the sentence just renders without the card name. Renaming it (to %{card}, say) is what I18n treats as an error, and Avo catches that and falls back to refresh plus the label rather than letting one translation break the dashboard header.
Already translated avo.refresh_dashboard?
The dashboard refresh control reads avo.cards.dashboard.refresh and falls back to avo.refresh_dashboard before the shipped English. That older key was never defined by Avo, so an app that wanted a translated dashboard refresh button had to define it itself — those translations keep working, and there is no rush to move them.
Three more strings the gem renders come from core's namespace, not its own, so they are already translated in every locale Avo ships — override them there rather than under avo.cards:
| Key | Where it appears |
|---|---|
avo.open_in_your_editor | the development-only editor link on a card and on a dashboard |
avo.no_cards_present | a dashboard with no visible cards |
avo.no_item_found | a table or list card's empty state, overridable per card with self.empty_message |
avo.open_in_your_editor is deliberately entity-free — it does not name the card or dashboard it opens. That is not an oversight to work around by interpolating the noun: the tooltip is attached to the thing it opens, and a noun interpolated into a sentence goes wrong in inflected languages, as Avo's own %{item} keys show (Polish fixes the adjective to one gender; German has to invert the sentence).
Hide the header
In cases where you need to embed some content that should fill the whole card (like a map, for example), you can choose to hide the label and ranges dropdown with display_header.
class Avo::Cards::UsersMetric < Avo::Cards::MetricCard
self.id = 'users_metric'
self.display_header = false
endLike the other base settings it also takes a block, so the header can come and go with the viewer or the card's parent.
self.display_header = -> { !parent.is_a?(Avo::Dashboards::Kiosk) }
Format
Option self.format is useful when you want to format the data that result returns from query.
Example without format:
class Avo::Cards::AmountRaised < Avo::Cards::MetricCard
self.id = "amount_raised"
self.label = "Amount raised"
self.prefix = "$"
def query
result 9001
end
end
Example with format:
class Avo::Cards::AmountRaised < Avo::Cards::MetricCard
self.id = "amount_raised"
self.label = "Amount raised"
self.prefix = "$"
self.format = -> {
number_to_social value, start_at: 1_000
}
def query
result 9001
end
end
Metric card
The metric card is your friend when you only need to display a simple big number. To generate one run bin/rails g avo:card users_metric --type metric.

Calculate results
To calculate your result, you may use the query method. After you make the query, use the result method to store the value displayed on the card.
In the query method you have access to a few variables like context (the App context), params (the request params), range (the range that was requested), dashboard, resource or parent (the current dashboard or resource the card is on), and current card.
class Avo::Cards::UsersMetric < Avo::Cards::MetricCard
self.id = 'users_metric'
self.label = 'Users count'
self.description = 'Some tiny description'
self.cols = 1
# self.rows = 1
# 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",
# }
# self.prefix = '~'
# self.suffix = '%'
# self.refresh_every = 10.minutes
def query
from = Date.today.midnight - 1.week
to = DateTime.current
if range.present?
if range.to_s == range.to_i.to_s
from = DateTime.current - range.to_i.days
else
case range
when 'TODAY'
from = DateTime.current.beginning_of_day
when 'MTD'
from = DateTime.current.beginning_of_month
when 'QTD'
from = DateTime.current.beginning_of_quarter
when 'YTD'
from = DateTime.current.beginning_of_year
when 'ALL'
from = Time.at(0)
end
end
end
result User.where(created_at: from..to).count
end
endDecorate the data using prefix and suffix
Some metrics might want to add a prefix or a suffix to display the data better.
class Avo::Cards::UsersMetric < Avo::Cards::MetricCard
self.id = 'users_metric'
self.prefix = '~'
self.suffix = '%'
end
prefix and suffix can be configured using a Proc.
Within this block, you gain access to all attributes of Avo::ExecutionContext along with the parent.
class Avo::Cards::UsersMetric < Avo::Cards::MetricCard
self.id = 'users_metric'
self.prefix = -> { params[:prefix] || parent.prefix }
self.suffix = -> { params[:suffix] || parent.suffix }
endChartkick card
A picture is worth a thousand words. So maybe a chart a hundred? Who knows? But creating charts in Avo is very easy with the help of the chartkick gem.
You start by running bin/rails g avo:card users_chart --type chartkick.
class Avo::Cards::UserSignups < Avo::Cards::ChartkickCard
self.id = 'user_signups'
self.label = 'User signups'
self.chart_type = :area_chart
self.description = 'Some tiny description'
self.cols = 2
# self.rows = 1
# self.chart_options = { library: { plugins: { legend: { display: true } } } }
# self.flush = true
# self.legend = false
# self.scale = false
# self.legend_on_left = false
# self.legend_on_right = false
def query
points = 16
i = Time.new.year.to_i - points
base_data =
Array
.new(points)
.map do
i += 1
[i.to_s, rand(0..20)]
end
.to_h
data = [
{ name: 'batch 1', data: base_data.map { |k, v| [k, rand(0..20)] }.to_h },
{ name: 'batch 2', data: base_data.map { |k, v| [k, rand(0..40)] }.to_h },
{ name: 'batch 3', data: base_data.map { |k, v| [k, rand(0..10)] }.to_h }
]
result data
end
end
Chart types
Using the self.chart_type class attribute you can change the type of the chart. Supported types are line_chart, pie_chart, column_chart, bar_chart, area_chart, and scatter_chart.
Customize chart
Because the charts are being rendered with padding initially, we offset that before rendering to make the chart look good on the card. To disable that, you can set self.flush = false. That will set the chart loose for you to customize further.
After you set flush to false, you can add/remove the scale and legend. You can also place the legend on the left or right using legend_on_left and legend_on_right.
These are just some of the predefined options we provide out of the box, but you can send different chartkick options to the chart using chart_options.
If you'd like to use Groupdate, Hightop, and ActiveMedian you should require them in your Gemfile. Only chartkick is required by default.
chart.js is supported for the time being. So if you need support for other types, please reach out or post a PR (🙏 PRs are much appreciated).
self.chart_options accepts callable blocks:
class Avo::Cards::ExampleAreaChart < Avo::Cards::ChartkickCard
self.chart_options = -> do
{
library: {
plugins: {
legend: {display: true}
}
}
}
end
endchart_options can also be declared when registering the card:
class Avo::Dashboards::Dashy < Avo::Dashboards::BaseDashboard
def cards
card Avo::Cards::ExampleAreaChart,
chart_options: {
library: {
plugins: {
legend: {display: true}
}
}
}
# OR
card Avo::Cards::ExampleAreaChart,
chart_options: -> do
{
library: {
plugins: {
legend: {display: true}
}
}
}
end
end
endThe blocks are executed using Avo::ExecutionContext. Within this blocks, you gain access to all attributes of Avo::ExecutionContext along with the parent, arguments and result_data.
Partial card
You can use a partial card to add custom content to a card. Generate one by running bin/rails g avo:card custom_card --type partial. That will create the card class and the partial for it.
class Avo::Cards::ExampleCustomPartial < Avo::Cards::PartialCard
self.id = "users_custom_card"
self.cols = 1
self.rows = 4
self.partial = "avo/cards/custom_card"
# self.display_header = false
end
You can embed a piece of content from another app using an iframe. You can hide the header using the self.display_header = false option. That will render the embedded content flush to the container.
# app/avo/cards/map_card.rb
class Avo::Cards::MapCard < Avo::Cards::PartialCard
self.id = "map_card"
self.label = "Map card"
self.partial = "avo/cards/map_card"
self.display_header = false
self.cols = 2
self.rows = 4
end<!-- app/views/avo/cards/_map_card.html.erb -->
<iframe
src="https://www.google.com/maps/embed?pb=!1m14!1m12!1m3!1d17991.835132857846!2d-73.98926852562143!3d40.742050491245955!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!5e0!3m2!1sen!2sro!4v1647079626880!5m2!1sen!2sro"
width="100%"
height="100%"
style="border:0;"
allowfullscreen=""
loading="lazy"
></iframe>
HTML card
Sinceavo-dashboards >= v4.1When the content is simple enough that a dedicated partial file feels like overhead, use an HTML card and build the body straight from Ruby. Implement a body method — every view helper (tag, safe_join, link_to, number_to_currency, render, main_app, …) is available directly on the card, and the return value can take three shapes.
This is a great place for your LLM to take over and spit out a version of HTML.
Inline ERB string
Return a plain string and it's compiled as an inline ERB template. The template has access to all view helpers plus a card local.
# app/avo/cards/newest_users.rb
class Avo::Cards::NewestUsers < Avo::Cards::HtmlCard
self.id = "newest_users"
self.label = "Newest users"
def body
<<~ERB
<ul class="divide-y divide-neutral-100">
<% User.order(created_at: :desc).limit(5).each do |user| %>
<li class="flex justify-between gap-2 px-4 py-2">
<%= link_to user.name, main_app.user_path(user) %>
<span class="text-content-secondary"><%= user.email %></span>
</li>
<% end %>
</ul>
ERB
end
endFor passing data in explicitly, call render inline: yourself with locals: — the result renders the same way:
def body
render inline: "<strong><%= count %></strong> signups", locals: {count: User.count}
endTag builder
Return tag builder output:
def body
tag.div class: "px-4 py-2" do
safe_join([
tag.strong(number_to_currency(149.99)),
tag.span("Total raised", class: "text-content-secondary")
])
end
endRenderables
Return anything render accepts — a View Component instance, a {partial:} hash — and the card renders it:
def body
MyStatsComponent.new(user: current_user)
enddef body
{partial: "avo/cards/my_stats", locals: {user: current_user}}
endWARNING
Inline templates go through ActionView, so interpolated values (<%= user.name %>) are HTML-escaped as usual. However, never build the template string itself from user input — pass dynamic values through ERB tags or locals:, not Ruby string interpolation.
Table card
Sinceavo-dashboards >= v4.1Use a table card to show a list of things — latest sign-ups, top products, failed jobs — styled like Avo's index table but fed by any query. Declare the columns as fields — the same DSL you use on resources — and return records from query.
# app/avo/cards/latest_users.rb
class Avo::Cards::LatestUsers < Avo::Cards::TableCard
self.id = "latest_users"
self.label = "Latest users"
self.cols = 2
self.rows = 2
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
endEach record from result becomes one row, and each field becomes one cell. Cells render through the same components as the resource Index views, so every field type and option — computed blocks, format_using, link_to_record, badge options:, and the rest — behaves exactly like it does on an index table.
INFO
The records you return must have an Avo resource registered for their model — the card resolves and renders fields through it.
Table cards are for top-N data — cap the row count in your query with limit. There is no pagination or sorting; if you need the full table experience, use a resource's index view instead.
Headers
Column headers derive from the field names. Pass name: to override one:
field :created_at, as: :date, name: "Joined"If you don't need column headers at all, you probably want the list card instead.
Row links
Set row_url to make every row a link. The block runs per row with record in scope:
self.row_url = -> { record_path(record) }For a new tab or a tooltip, return a hash instead — the same semantics as discreet information:
self.row_url = -> {
{url: record_path(record), target: :_blank, tooltip: "View #{record.name}"}
}- Each hash value may itself be a block receiving
record. urlacceptshttp,https,mailto, and relative URLs; anything else (likejavascript:) is ignored.- List card rows render a real anchor stretched over the row, so middle-click, copy-link, and no-JS navigation work. Table rows navigate through the same JavaScript as
click_row_to_view_recordon index tables — a<tr>can't be wrapped in an anchor. Either way, links inside the row (like amailto:cell) still win over the row link.
Density
Rows use the global config.density unless the card overrides it with density:
self.density = :tight # :tight, :normal, :relaxedEmpty state
When the query returns no rows the card shows a translated "No record found" message. Override it per card with empty_message:
self.empty_message = "No sign-ups this week"
# or
self.empty_message = -> { I18n.t("cards.no_signups") }Ranges and refreshing
Table cards support the same ranges, initial_range, and refresh_every settings as every other card. The selected range is available as range inside query — using it as the query's limit is a common pattern.
WARNING
refresh_every reloads the whole card, which resets the scroll position of a tall table. Prefer it on short tables.
The card's rows setting caps the table's height rather than fixing it. A short table is as tall as its records; once the records pass the cap the card stops growing and they scroll inside it, with the column headers staying pinned. An empty table is the height of its "No record found" message, whatever rows says.
List card
Sinceavo-dashboards >= v4.1Use a list card whenever you want to show a list of things without table semantics — it renders a real <ul>, not a <table>, and there are no column headers. The first field is each row's primary content and every other field trails at the end edge — great for badges, booleans, or timestamps.
# app/avo/cards/active_users.rb
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 :email, as: :text
field :active, as: :boolean
end
def query
result User.active.order(:name).limit(5)
end
endFields, row links, density, the empty state, empty_message, ranges, and the height cap all work the same way as on the table card.
Cards visibility
It's common to show the same card to multiple types of users (admins, regular users). In that scenario you might want to hide some cards for the regular users and show them just to the admins.
You can use the visible option to do that. It can be a boolean or a block where you can access the params, current_user, context, parent, and card object.
class Avo::Cards::UsersCount < Avo::Cards::MetricCard
self.id = "users_metric"
self.label = "Users count"
self.visible = -> do
# You have access to:
# context
# params
# parent (the current dashboard or resource)
# dashboard (will be nil when parent is resource)
# resource (will be nil when parent is dashboard)
# current card
true
end
def query
result User.count
end
endYou may also control the visibility from the parent class.
class Avo::Dashboards::Dashy < Avo::Dashboards::BaseDashboard
def cards
card Avo::Cards::UsersCount, visible: -> { true }
end
endclass Avo::Resources::User < Avo::BaseResource
def cards
card Avo::Cards::UsersCount, visible: -> { true }
end
endA card hidden by visible is not served by its URL either. Resource cards follow the resource's policy: index? for cards rendered without a record, show? on the record otherwise, with the record found through the policy scope.
Dividers
You may want to separate the cards. You can use dividers to do that.
class Avo::Dashboards::Dashy < Avo::Dashboards::BaseDashboard
def cards
card Avo::Cards::ExampleColumnChart
card Avo::Cards::ExamplePieChart
card Avo::Cards::ExampleBarChart
divider label: "Custom partials"
card Avo::Cards::ExampleCustomPartial
card Avo::Cards::MapCard
end
end
Dividers can be a simple line between your cards or have some text on them that you control using the label option. When you don't want to show the line, you can enable the invisible option, which adds the divider but does not display a border or label.
Dividers visibility
You might want to conditionally show/hide a divider based on a few factors. You can do that using the visible option.
divider label: "Custom partials", visible: -> {
# You have access to:
# context
# params
# parent (the current dashboard or resource)
# dashboard (will be nil when parent is resource)
# resource (will be nil when parent is dashboard)
true
}View-specific card methods
Similar to view-specific field methods like index_fields and show_fields, resources can define view-specific card methods to control which cards render on each page.
Resolution order by view
| View | Specific method | Context fallback | Final fallback |
|---|---|---|---|
| Index | index_cards | display_cards | cards |
| Show | show_cards | display_cards | cards |
| New | new_cards | form_cards | cards |
| Edit | edit_cards | form_cards | cards |
Avo picks the first method available in the order listed above for the current view.
Example
Assume this card class:
class Avo::Cards::AmountRaised < Avo::Cards::MetricCard
self.id = "amount_raised"
self.label = "Amount raised"
self.prefix = "$"
self.format = -> {
number_to_social value, start_at: 1_000
}
def query
result 9001
end
endDefine where it should appear on the Project resource:
class Avo::Resources::Project < Avo::BaseResource
# Show page uses `show_cards` first
def show_cards
card Avo::Cards::AmountRaised
end
# Index and show pages fall back to `display_cards` only when
# their specific method (`index_cards`/`show_cards`) is not defined
def display_cards
card Avo::Cards::AmountRaised
end
# New and edit pages fall back to `form_cards` when `new_cards`/`edit_cards` are not defined
def form_cards
card Avo::Cards::AmountRaised
end
endWith the setup above, the card will render on the Project show page via show_cards. If you remove show_cards, Avo will use display_cards for the show page. For new/edit pages, Avo will use form_cards unless you define new_cards or edit_cards respectively.
Cards on association tables
A resource's index cards also render when that index is a has_many table on another record's page. There, params[:related_name] holds the association name and params[:via_record_id] the parent record's id, so a card can count the parent's records instead of all of them. Look the parent up through its resource's find_record, which understands slugs and other custom ids:
class Avo::Cards::PostsCount < Avo::Cards::MetricCard
self.id = "posts_count"
self.label = "Posts count"
def query
scope = if params[:via_record_id].present?
Avo::Resources::User.find_record(params[:via_record_id]).posts
else
Post
end
result scope.count
end
endShow a card in a custom tool
Sinceavo-dashboards >= v4.3.2Card classes need a dashboard or a resource to render on. When a custom tool wants the same look with its own data, render Avo::Cards::StandaloneCardComponent and pass it plain values. Its block is the card's body. Put Avo::Cards::MetricComponent inside it to show a number the way a metric card does.
# app/controllers/avo/tools_controller.rb
class Avo::ToolsController < Avo::ApplicationController
def traffic
@page_title = "Traffic"
@range = params[:visitors_range].presence || "7"
@visitors = Visit.where(created_at: @range.to_i.days.ago..).count
end
end<%# app/views/avo/tools/traffic.html.erb %>
<div class="dashboard-grid dashboard-grid--cols-3">
<%= render Avo::Cards::StandaloneCardComponent.new(
label: "Recent visitors",
description: "Unique visitors in the selected range",
ranges: {"7 days" => 7, "30 days" => 30},
range: @range,
range_param: :visitors_range,
cols: 2
) do %>
<%= render Avo::Cards::MetricComponent.new(value: number_with_delimiter(@visitors)) %>
<% end %>
<%= render Avo::Cards::StandaloneCardComponent.new(label: "Notes") do %>
<div class="px-4 pb-4">Any markup your tool needs.</div>
<% end %>
</div>The component does not query anything. Your controller action computes the data, and the component only draws it.
Let the user pick a range
Pass ranges and the header shows the same dropdown a dashboard card has. Picking a range reloads the page with the value in the URL, under the range_param you chose, so your action reads it from params. Give each card its own range_param when a page has several, or one dropdown changes all of them.
Size cards on a grid
cols and rows work like a card class's self.cols and self.rows. They only take effect inside the dashboard grid, so wrap your cards in <div class="dashboard-grid dashboard-grid--cols-N">, where N is between 3 and 6. Leave both out and the card fills the width of whatever holds it.