Skip to content
Looking for every option?See the full API reference →

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:

ruby
# 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
end

With 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 ​

  1. Record selection. On the Index view the user selects one or more records (bulk); on the Show view or row controls the current record is the target. Standalone actions skip this phase.
  2. Trigger. The user picks the action from the Actions dropdown or a custom control.
  3. Confirmation modal (optional). A modal shows the action's message and any fields to fill out. The user runs or cancels. Set confirmation to false to skip this phase and execute immediately.
  4. Execution. Your handle method runs with the records, field values, current user, and request. You give feedback and pick a response.

Generate an action ​

bash
bin/rails generate avo:action toggle_inactive

This 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:

bash
# 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_user

Register the action on a resource ​

Declare the actions available on a resource inside its actions method:

ruby
# 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
end

Pass 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:

ruby
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
end
ruby
class Avo::Actions::ToggleInactive < Avo::BaseAction
  def handle(**args)
    if arguments[:special_message]
      succeed "I love 🥑"
    else
      succeed "Success response ✌️"
    end
  end
end

WARNING

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.

ruby
# app/avo/actions/toggle_inactive.rb
class Avo::Actions::ToggleInactive < Avo::BaseAction
  def fields
    field :notify_user, as: :boolean
    field :message, as: :textarea
  end
end

Check 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.

ruby
# 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
end

INFO

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:

ruby
# app/avo/actions/assign_user.rb
def fields
  field :user_id,
    as: :select,
    options: -> { User.order(:name).pluck(:name, :id) },
    include_blank: true
end

A tags field with fetch_values_from in select mode covers a long list that needs search:

ruby
# 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"
end

The 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

ruby
# 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
end

WARNING

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 (records is an alias)
  • fields — the values submitted through the action's form fields
  • current_user — the currently authenticated user
  • resource — the Avo resource that triggered the action
  • request — the current ActionDispatch::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.

ruby
# 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
end

Give 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.

ruby
def handle(**args)
  succeed "Success response ✌️"
  warn "Warning response ✌️"
  inform "Info response ✌️"
  error "Error response ✌️"
end
Avo action feedback notifications: success, info, warning, and error alerts stacked.

Each method takes an optional timeout in milliseconds — or :forever to keep the notification open until the user dismisses it:

ruby
succeed "Task completed successfully", timeout: 5000
warn "Important warning - requires attention", timeout: :forever

If 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:

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.

ruby
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
end
ruby
class 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
end

You 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):

ruby
# 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"
end

If 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:

ruby
# app/avo/actions/global_report.rb
class Avo::Actions::GlobalReport < Avo::BaseAction
  self.name = "Generate Global Report"
  self.standalone = true
end

TIP

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:

ruby
# 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
end

index_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.

ruby
# 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
end

While 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:

ruby
# 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? }
end

Control 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:

ruby
# app/avo/actions/global_report.rb
class Avo::Actions::GlobalReport < Avo::BaseAction
  self.visible = -> { view.index? }
end

To 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:

ruby
class Avo::Actions::GlobalReport < Avo::BaseAction
  self.authorize = -> { current_user.is_admin? }
end

Both 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.

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:

ruby
# 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
end

If you don't have an instantiated resource at hand — a custom partial on a dashboard, say — build one yourself, with or without a record:

ruby
path, data = Avo::Actions::City::Update.link_arguments(
  resource: Avo::Resources::City.new(record: city)
)

link_to "Update city", path, data: data

The 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:

ruby
# 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
end

With 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.