Actions
Actions let you run custom operations on one or many records — or no records at all — straight from the UI. Deactivate a user, send a notification, export a CSV, trigger a background job: anything you can express in Ruby can become an entry in the Actions dropdown, optionally with a confirmation modal and a form that collects extra input before running.
An action is a plain Ruby class with a handle method, registered on one or more resources:
# app/avo/actions/toggle_inactive.rb
class Avo::Actions::ToggleInactive < Avo::BaseAction
self.name = "Toggle Inactive"
def handle(query:, **args)
query.each do |record|
record.update! inactive: !record.inactive
end
succeed "Toggled status for #{query.count} records"
end
end
# app/avo/resources/user.rb
class Avo::Resources::User < Avo::BaseResource
def actions
action Avo::Actions::ToggleInactive
end
endWith no extra configuration the action shows up in the Actions dropdown on the Index and Show views (it's hidden on New by default), asks "Are you sure you want to run this action?" in a confirmation modal, runs handle with the selected records, shows a green success notification, and reloads the page.
What happens when an action runs
- Record selection. On the
Indexview the user selects one or more records (bulk); on theShowview or row controls the current record is the target. Standalone actions skip this phase. - Trigger. The user picks the action from the Actions dropdown or a custom control.
- Confirmation modal (optional). A modal shows the action's message and any fields to fill out. The user runs or cancels. Set
confirmationtofalseto skip this phase and execute immediately. - Execution. Your
handlemethod runs with the records, field values, current user, and request. You give feedback and pick a response.
Generate an action
bin/rails generate avo:action toggle_inactiveThis creates app/avo/actions/toggle_inactive.rb with a commented-out skeleton for visible, fields, and handle. Pass --standalone to generate an action that doesn't need selected records, and use a namespace to group related actions:
# Standalone action
bin/rails generate avo:action generate_monthly_report --standalone
# Namespaced action -> app/avo/actions/admin/approve_user.rb
bin/rails generate avo:action admin/approve_userRegister the action on a resource
Declare the actions available on a resource inside its actions method:
# app/avo/resources/user.rb
class Avo::Resources::User < Avo::BaseResource
def actions
# User status actions
action Avo::Actions::ActivateUser
action Avo::Actions::DeactivateUser
divider label: "Communication"
action Avo::Actions::SendWelcomeEmail, icon: "heroicons/outline/envelope"
end
endPass icon to show an icon next to the action in the dropdown, and use divider to separate actions into logical groups, with an optional label.
If you want to send custom data to the action, pass arguments — a hash (or a proc that returns one) that becomes available everywhere in the action class:
class Avo::Resources::User < Avo::BaseResource
def actions
action Avo::Actions::ToggleInactive,
arguments: {
special_message: true
}
# Or as a proc to make it dynamic
action Avo::Actions::ToggleInactive,
arguments: -> do
{
special_message: resource.view.index? && current_user.is_admin?
}
end
end
endclass Avo::Actions::ToggleInactive < Avo::BaseAction
def handle(**args)
if arguments[:special_message]
succeed "I love 🥑"
else
succeed "Success response ✌️"
end
end
endWARNING
When using Pundit policies, access to actions is restricted with the act_on? method. If you think you should see an action and you don't, check the policy. More info here.
INFO
You may use the custom controls feature to show actions outside the dropdown, as standalone buttons.
Collect input with fields
An action can define fields, shown to the user in the action's modal. Most work the same way as fields on resources. When the action runs on a single record the fields are hydrated from that record; otherwise they render empty. The submitted values arrive in handle as the fields argument.
Every field you declare here renders in the modal. The hide_on and show_on marks belong to resource views and do not filter an action's fields. A field with no form component, such as badge, renders read-only, as it does on the Show view, which lets you show context next to the inputs. The one exception: a badge declared with show_on: :forms renders as a select of its options.
# app/avo/actions/toggle_inactive.rb
class Avo::Actions::ToggleInactive < Avo::BaseAction
def fields
field :notify_user, as: :boolean
field :message, as: :textarea
end
endCheck out the Fields page for everything fields can do.
Choose an associated record
A belongs_to field renders in the action's modal wherever the action was started from: a record's Show view, one or several records selected on the Index view, or a standalone action with no record at all. Since avo >= v4.2.9 On a single record it opens with that record's association preselected; with several records or none it opens empty.
# app/avo/actions/assign_user.rb
class Avo::Actions::AssignUser < Avo::BaseAction
def fields
field :user, as: :belongs_to
end
def handle(query:, fields:, **)
query.each do |record|
record.update! user_id: fields[:user_id]
end
succeed "Assigned the selected records."
end
endINFO
Before avo 4.2.9 the field only rendered when the action had exactly one record, so older actions guard it with visible: -> { resource.record.present? } or use one of the alternatives below. Drop the guard to show the field everywhere.
For a value that isn't an association, use a plain field. A select field covers a short list of choices:
# app/avo/actions/assign_user.rb
def fields
field :user_id,
as: :select,
options: -> { User.order(:name).pluck(:name, :id) },
include_blank: true
endA tags field with fetch_values_from in select mode covers a long list that needs search:
# app/avo/actions/assign_user.rb
def fields
field :user_id,
as: :tags,
mode: :select,
enforce_suggestions: true,
fetch_values_from: "/avo/resources/users/action_options"
endThe endpoint receives the search text in params[:q] and returns objects with value and label keys. Both alternatives submit the selected ID as fields[:user_id].
Read a belongs_to field in handle
A belongs_to field arrives in fields under its foreign key, not under the field's id: field :user, as: :belongs_to arrives as fields[:user_id].
A polymorphic one arrives as two keys, the type and the id. Since avo >= v4.2.7
# app/avo/actions/move_review.rb
class Avo::Actions::MoveReview < Avo::BaseAction
def fields
field :reviewable, as: :belongs_to, polymorphic_as: :reviewable, types: [Post, Team]
end
def handle(query:, fields:, **)
query.each do |review|
review.update!(
reviewable_type: fields[:reviewable_type],
reviewable_id: fields[:reviewable_id]
)
end
succeed "Moved the review."
end
endWARNING
Both values reach handle as the strings the form submitted. Unlike a belongs_to field on a resource form, an action does not check the type against the field's types list, so check it yourself before you store it or turn it into a class.
Write the handle method
handle is where your business logic lives. It receives keyword arguments:
query— the selected record(s); a single record is wrapped in an array for consistency (recordsis an alias)fields— the values submitted through the action's form fieldscurrent_user— the currently authenticated userresource— the Avo resource that triggered the actionrequest— the currentActionDispatch::Request
Records deleted after selection
Someone else may delete a record between the moment you check it on the Index page and the moment you run the action. Avo drops that record and runs the action on the rest, so you don't need to rescue ActiveRecord::RecordNotFound yourself.
If every selected record is gone, query arrives empty — guard with return error "No record selected" if query.blank? when that matters.
# app/avo/actions/toggle_inactive.rb
class Avo::Actions::ToggleInactive < Avo::BaseAction
self.name = "Toggle Inactive"
def fields
field :notify_user, as: :boolean
field :message, as: :textarea
end
def handle(query:, fields:, current_user:, resource:, request:, **args)
query.each do |record|
record.update! inactive: !record.inactive
record.notify(fields[:message]) if fields[:notify_user]
end
succeed "Successfully toggled status for #{query.count} records"
end
endGive feedback to the user
After the action runs, respond with one or more notifications: succeed (green), inform (blue), warn (orange), or error (red). With no explicit feedback, Avo shows an "Action ran successfully" info notification.
def handle(**args)
succeed "Success response ✌️"
warn "Warning response ✌️"
inform "Info response ✌️"
error "Error response ✌️"
end
Each method takes an optional timeout in milliseconds — or :forever to keep the notification open until the user dismisses it:
succeed "Task completed successfully", timeout: 5000
warn "Important warning - requires attention", timeout: :foreverIf you don't want any notification — a redirect scenario, for example — use silent.
Control what happens after execution
Besides feedback, handle picks how the UI responds. The default is a full-page reload. You can instead:
redirect_toa different pathdownloada generated filekeep_modal_opento show errors while preserving the user's inputclose_modal(aliasdo_nothing) to close the modal and leave the page as isreload_recordsto refresh only the affected table rows or grid cardsnavigate_to_actionto chain into another actionappend_to_responseto add your own turbo stream responses
See the API reference for each response's behavior and signature.
Build a multi-step flow
Because navigate_to_action can pass arguments along, you can chain actions into a wizard: the first action collects which fields to update, the second renders only those fields and performs the update.
class Avo::Actions::City::PreUpdate < Avo::BaseAction
self.name = "Update"
def fields
field :name, as: :boolean
field :population, as: :boolean
end
def handle(query:, fields:, **args)
navigate_to_action Avo::Actions::City::Update,
arguments: {
cities: query.map(&:id),
render_name: fields[:name],
render_population: fields[:population]
}
end
endclass Avo::Actions::City::Update < Avo::BaseAction
self.name = "Update"
self.visible = -> { false }
def fields
field :name, as: :text if arguments[:render_name]
field :population, as: :number if arguments[:render_population]
end
def handle(fields:, **args)
City.find(arguments[:cities]).each do |city|
city.update! fields
end
succeed "City updated!"
end
endYou can try this flow on the avodemo: select a record, run the "Update" action, choose the fields, and update them in the follow-up action.
Customize the modal
The modal's texts can each be a string or a block — blocks run in Avo::ExecutionContext with access to resource, record, view, arguments, query, and index_query (nil while name renders in the dropdown):
name— the action's display name in the dropdown and modaldescription— a short explanation under the name in the modal headermessage— the confirmation messageconfirm_button_label/cancel_button_label— the button labels
# app/avo/actions/release_fish.rb
class Avo::Actions::ReleaseFish < Avo::BaseAction
self.name = "Release fish"
self.description = "Release the fish back into the ocean"
self.message = -> {
record.present? ? "Are you sure you want to release #{record.name}?" : "Are you sure you want to release the fish?"
}
self.confirm_button_label = "Release fish"
self.cancel_button_label = "Cancel release"
endIf the action is safe to run without asking, set confirmation to false and it executes immediately on trigger. To stop the modal from closing when the user clicks outside it, set close_modal_on_backdrop_click to false. To give a larger form more room, set modal_width. To leave out the modal's buttons, set hide_confirm_button or hide_cancel_button: hiding Run also disables Mod+Enter, while Escape and the header X keep closing the modal.
Run an action without records
Standalone actions aren't tied to selected records — global reports, maintenance tasks, background jobs. They stay enabled in the Actions dropdown even with nothing selected and can live alongside regular actions on the same resource.
Generate one with the --standalone flag, or set the attribute on an existing action:
# app/avo/actions/global_report.rb
class Avo::Actions::GlobalReport < Avo::BaseAction
self.name = "Generate Global Report"
self.standalone = true
endTIP
Standalone actions pair well with fields to collect the input the operation needs.
Work with every record on the Index
query holds only the records the user selected. When the action needs the whole list the user is looking at, read index_query: the Index query with the current filters, scopes, search, and sort applied, before pagination. It is the same whether the user selected one record, a page, all matching records, or nothing, so it suits standalone exports of "everything I filtered".
It is available in handle, in fields, and in the modal's text blocks:
# app/avo/actions/export_csv.rb
class Avo::Actions::ExportCsv < Avo::BaseAction
self.standalone = true
self.visible = -> { view.index? }
self.message = -> { "Export #{index_query&.count.to_i} records?" }
def fields
field :only_selected, as: :boolean, help: "Out of #{index_query&.count.to_i} matching records"
end
def handle(query:, fields:, **)
records = fields[:only_selected] ? query : index_query
# ...
end
endindex_query is nil when there is no list to read it from: on a Show or Edit page (an action started from a has-many table on that page gets that table's query), when the filters match no records, on a map view with its table hidden, on an array resource that paginates at the source, and when the query can't be serialized (see Select All). A name block also sees nil while the dropdown renders. Guard with index_query&. wherever one of those can happen.
Run actions from an association panel
An association panel that lists records, such as has_many or has_and_belongs_to_many, carries the actions declared on the associated resource. They run on the rows you check inside the panel, so it behaves like a small Index view. An action that isn't standalone stays disabled until you check at least one row. Since avo >= v4.3.3 To run one on a single row without checking it, use a row control, which hydrates the action with that row's record.
# app/avo/resources/team.rb
class Avo::Resources::Team < Avo::BaseResource
def fields
# The Team members panel offers the actions declared on Avo::Resources::User
field :team_members, as: :has_many
end
endWhile that dropdown renders, record is nil, as on the main Index view, so a name or arguments block has to handle its absence. The panel's parent record reaches the visible block as parent_resource.record, which lets you hide an action depending on the record holding the panel:
# app/avo/actions/toggle_inactive.rb
class Avo::Actions::ToggleInactive < Avo::BaseAction
# `parent_resource` is nil outside an association panel.
self.visible = -> { parent_resource.nil? || !parent_resource.record.archived? }
endControl visibility and authorization
By default actions show on every view except New. Use visible to change where an action appears — for example, only on the Index view:
# app/avo/actions/global_report.rb
class Avo::Actions::GlobalReport < Avo::BaseAction
self.visible = -> { view.index? }
endTo restrict who can run an action, use authorize. Unauthorized actions are hidden, and the controller re-evaluates the check on execution, so a bad actor can't run one by crafting a request:
class Avo::Actions::GlobalReport < Avo::BaseAction
self.authorize = -> { current_user.is_admin? }
endBoth accept a boolean or a block executed in Avo::ExecutionContext.
Inside every block of an action, visible, authorize, the modal texts, fields, and handle, view is the view the action was started from: index, show, or edit. It stays the same while the modal renders and while the action runs, and it is never new. Avo checks the value against the request's route, so show and edit need a record in the URL and a crafted request cannot claim another page. Use it to adapt behavior, for example reload_records if view.index?. Gate who may run an action on current_user, and single-versus-bulk on query.size or record.
Trigger an action from a link
You may want a link somewhere in the UI — a field on the Index view, a dashboard card — that opens an action's modal directly. Call the action class's link_arguments method with a resource instance; it returns the [path, data] pair a proper action link needs:
# app/avo/resources/city.rb
class Avo::Resources::City < Avo::BaseResource
def fields
field :name, as: :text, name: "Name (click to edit)", only_on: :index do
path, data = Avo::Actions::City::Update.link_arguments(
resource: resource,
arguments: {
cities: Array[resource.record.id],
render_name: true
}
)
link_to resource.record.name, path, data: data
end
end
endIf you don't have an instantiated resource at hand — a custom partial on a dashboard, say — build one yourself, with or without a record:
path, data = Avo::Actions::City::Update.link_arguments(
resource: Avo::Resources::City.new(record: city)
)
link_to "Update city", path, data: dataThe arguments are encrypted and Base64-encoded before being put in the URL, so it's safe to pass sensitive data.
Read params from the page that triggered the action
The action's modal is rendered in a new request, so query params present on the Index or Show page aren't directly available. To read them — for example to prefill a field — parse request.referer:
# app/avo/actions/dummy_action.rb
class Avo::Actions::DummyAction < Avo::BaseAction
def fields
field :some_field, as: :hidden, default: -> {
# Parse the URL of the page that triggered the action
parent_params = URI.parse(request.referer).query
.split("&")
.map { |param| param.split("=") }
.to_h
.with_indifferent_access
(parent_params[:hey] == "ya") ? :yes : :no
}
end
endWith this in place, visiting https://example.com/avo/resources/users?hey=ya and running the action sets the field's default based on the hey param.
StimulusJS
Actions play well with custom Stimulus controllers. Follow the JavaScript guide for more information.