agentleFS
garthcodesgarthcodes/claude-skills/skills/avo-admin-expert/SKILL.md

avo-admin-expert

Skill2 starsChanged 35 days agoMITView on GitHub

Expert at building Avo Admin resources, actions, filters, dashboards, and cards for Rails applications. Use when user asks to create admin panels, manage resources in Avo, build dashboards, or customize the admin interface. Specializes in Avo 3.x Community Edition patterns with Pundit authorization and multi-tenant support.

The file

Show its rules and text

We split this skill into the rules it gives an agent

23 rules. About 0 are common, 21 are distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈21

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Keep it simple: - Use Avo's built-in features before custom solutions

    line 13
  2. Distinctive:

    Security first: - Always implement proper authorization

    line 15
  3. Distinctive:

    Community license: - Only use features available in the free tier

    line 18
  4. Distinctive:

    Use Pundit policies for authorization.

    line 750
  5. Distinctive:

    Use descriptive, scoped cache keys:

    line 884
  6. Distinctive:

    Use sidebar for associations

    line 956
  7. Distinctive:

    Add meaningful search configuration

    line 957
  8. Very specific:

    Always set self.includes for eager loading

    line 961
  9. Very specific:

    Use build_stubbed in tests when possible

    line 963
  10. Distinctive:

    Use Pundit policies for all resources

    line 968
  11. Distinctive:

    Use descriptive field names

    line 975
  12. Distinctive:

    Add help text for complex fields

    line 976
  13. Distinctive:

    Follow existing patterns in codebase

    line 982
  14. Distinctive:

    Write tests for custom actions

    line 983
  15. Distinctive:

    Use concerns for shared behavior

    line 985
  16. Distinctive:

    When building Avo admin features:

    line 1337
  17. Distinctive:

    Check existing patterns: - Look at similar resources in codebase

    line 1340
  18. Distinctive:

    Create resource with basic fields

    line 1343
  19. Distinctive:

    Add associations and panels

    line 1344
  20. Distinctive:

    Add actions and filters

    line 1345
  21. Distinctive:

    Add audit logging if needed

    line 1347
  22. Distinctive:

    Test thoroughly: - Verify authorization and data access

    line 1348
  23. Distinctive:

    Document: - Add help text and descriptions

    line 1349

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Avo Admin Expert

You are an expert at building admin interfaces using Avo Admin for Ruby on Rails. You specialize in creating clean, functional admin panels that follow Avo best practices and integrate seamlessly with existing Rails patterns.

Core Principles

  1. Keep it simple - Use Avo's built-in features before custom solutions
  2. Follow conventions - Match existing patterns in the codebase
  3. Security first - Always implement proper authorization
  4. Multi-tenant aware - Respect tenant boundaries in all queries
  5. HIPAA compliance - Log sensitive data access when required
  6. Community license - Only use features available in the free tier

Technology Stack Context

This is a Rails 8 application with:

  • Avo: 3.28.0 (Community Edition)
  • Ruby: 3.3.5
  • Database: PostgreSQL with UUID primary keys
  • Authentication: Devise with Passwordless
  • Authorization: Pundit
  • Multi-tenancy: ActsAsTenant
  • Soft Deletes: Custom SoftDeletable concern

Community License Limitations

Available in Community Edition:

  • Resources with all field types
  • Actions (bulk and single record)
  • Filters (boolean, select, text, date)
  • Dashboards and Cards (metric, chartkick, partial)
  • Basic search
  • Pundit authorization integration
  • Turbo integration

NOT Available (Pro/Advanced only):

  • direct_upload: true for file fields
  • Resource tools
  • Menu editor
  • Custom fields (advanced)
  • Branding customization
  • Multi-language support
  • Priority support

Directory Structure

app/avo/
├── resources/           # Avo admin resources
│   └── *.rb
├── actions/             # Custom Avo actions
│   └── *.rb
├── filters/             # Custom Avo filters
│   └── *.rb
├── cards/               # Dashboard metric cards
│   └── *.rb
├── dashboards/          # Admin dashboards
│   └── *.rb
└── concerns/            # Shared concerns
    └── *.rb

app/controllers/avo/     # Avo controller stubs
└── *_controller.rb

config/initializers/avo.rb  # Global configuration

RESOURCES

Resources define how models are displayed and managed in Avo.

Resource File Structure

# app/avo/resources/client.rb
class Avo::Resources::Client < Avo::BaseResource
  # Title shown in UI (required)
  self.title = :full_name

  # Eager load associations to prevent N+1
  self.includes = [:organization, :therapists, :appointments]

  # Search configuration using Ransack
  self.search = {
    query: -> {
      query.ransack(
        first_name_cont: params[:q],
        last_name_cont: params[:q],
        email_cont: params[:q],
        m: 'or'
      ).result(distinct: false)
    }
  }

  # Define fields
  def fields
    # Main panel fields
    main_panel do
      field :id, as: :id
      field :first_name, as: :text, sortable: true
      field :last_name, as: :text, sortable: true
      field :email, as: :text, sortable: true, link_to_record: true
      field :status, as: :badge, map: {
        active: :success,
        inactive: :warning,
        archived: :danger
      }
      field :created_at, as: :datetime, sortable: true, readonly: true

      # Sidebar for associations
      sidebar do
        field :organization, as: :belongs_to
        field :therapists, as: :has_many, through: :client_therapists
        field :appointments, as: :has_many
      end
    end
  end

  # Define actions available for this resource
  def actions
    action Avo::Actions::SoftDelete
    action Avo::Actions::Restore
  end

  # Define filters available for this resource
  def filters
    filter Avo::Filters::SoftDeleteStatusFilter
  end
end

Field Types Reference

Basic Fields:

field :name, as: :text                    # Text input
field :description, as: :textarea         # Multi-line text
field :bio, as: :trix                     # Rich text editor
field :notes, as: :markdown               # Markdown editor
field :is_active, as: :boolean            # Checkbox
field :count, as: :number                 # Number input
field :password, as: :password            # Password field (hidden)

Date/Time Fields:

field :created_at, as: :datetime          # Date and time
field :birth_date, as: :date              # Date only
field :start_time, as: :time              # Time only

Selection Fields:

# Select from enum
field :status, as: :select, enum: ::Client::STATUSES

# Select from array
field :priority, as: :select, options: {
  'High' => 'high',
  'Medium' => 'medium',
  'Low' => 'low'
}

# Badge display for status fields
field :status, as: :badge, map: {
  draft: :info,
  pending: :warning,
  active: :success,
  failed: :danger,
  cancelled: :secondary
}

# Tags field
field :roles, as: :tags

Association Fields:

field :organization, as: :belongs_to, searchable: true
field :user, as: :belongs_to, searchable: true, sortable: true

field :items, as: :has_many
field :documents, as: :has_many, through: :document_assignments

field :profile, as: :has_one

File Fields:

field :avatar, as: :file                  # Single file
field :attachments, as: :files            # Multiple files
# Note: direct_upload requires Pro license

Special Fields:

field :id, as: :id                        # UUID/ID field

# Currency display (cents to dollars)
field :amount_cents, as: :number, format_using: -> {
  "$#{(value / 100.0).round(2)}"
}

# External link
field :website, as: :text, format_using: -> {
  link_to value, value, target: '_blank' if value.present?
}

Field Options

field :name, as: :text,
  # Display options
  sortable: true,              # Allow sorting
  link_to_record: true,        # Make clickable link to show page
  readonly: true,              # Display only, no editing
  disabled: true,              # Shown but not editable
  placeholder: 'Enter name',   # Placeholder text
  help: 'Full legal name',     # Help text below field

  # Visibility options
  hide_on: [:index],           # Hide on specific views
  show_on: [:show, :edit],     # Show only on specific views
  visible: -> { current_user.admin? },  # Dynamic visibility

  # Formatting
  format_using: -> { value.upcase },    # Transform display value

  # Filtering
  filterable: true             # Allow filtering on this field

Custom Find Method

For models with custom to_param (like subdomain lookups):

class Avo::Resources::Organization < Avo::BaseResource
  self.title = :name
  self.includes = [:users, :platform_invoices]

  # Handle both UUID and subdomain lookups
  self.find_record_method = -> {
    if id.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
      query.find_by(id: id)
    else
      query.find_by(subdomain: id)
    end
  }
end

Panel Organization

def fields
  main_panel do
    # Primary fields in main content area
    field :id, as: :id
    field :name, as: :text
    field :email, as: :text

    # Sidebar for related data
    sidebar do
      field :organization, as: :belongs_to
      field :created_at, as: :datetime, readonly: true
      field :updated_at, as: :datetime, readonly: true
    end
  end

  # Additional panel for grouped fields
  panel name: 'Contact Information' do
    field :phone, as: :text
    field :address, as: :textarea
    field :city, as: :text
    field :state, as: :text
    field :zip, as: :text
  end

  # Tab groups for complex resources
  tabs do
    tab 'Details' do
      field :description, as: :textarea
    end

    tab 'History' do
      field :audit_logs, as: :has_many
    end
  end
end

ACTIONS

Actions perform operations on selected records.

Basic Action Structure

# app/avo/actions/archive_client.rb
class Avo::Actions::ArchiveClient < Avo::BaseAction
  self.name = 'Archive Client'
  self.message = 'Are you sure you want to archive the selected clients?'

  # Confirmation button labels
  self.confirm_button_label = 'Archive'
  self.cancel_button_label = 'Cancel'

  # Only show for specific resources
  self.visible = -> {
    # Check if this is the Client resource
    resource.class == Avo::Resources::Client
  }

  def handle(query:, fields:, current_user:, resource:, **args)
    archived_count = 0

    query.each do |record|
      next if record.archived?

      record.update(status: :archived, archived_at: Time.current)
      archived_count += 1
    end

    succeed "Successfully archived #{archived_count} client(s)."
  end
end

Action with Form Fields

class Avo::Actions::ChangeStatus < Avo::BaseAction
  self.name = 'Change Status'
  self.message = 'Select the new status for the selected records.'

  def fields
    field :status, as: :select, options: {
      'Active' => 'active',
      'Inactive' => 'inactive',
      'Pending' => 'pending'
    }, required: true

    field :reason, as: :textarea, placeholder: 'Optional reason for status change'
  end

  def handle(query:, fields:, current_user:, resource:, **args)
    new_status = fields[:status]
    reason = fields[:reason]

    query.each do |record|
      record.update(
        status: new_status,
        status_changed_at: Time.current,
        status_change_reason: reason
      )

      # Log the change
      AuditLog.create(
        user: current_user,
        action: 'status_change',
        record: record,
        details: { new_status: new_status, reason: reason }
      )
    end

    succeed "Status updated to '#{new_status}' for #{query.count} record(s)."
  end
end

Standalone Action (No Records Required)

class Avo::Actions::GenerateReport < Avo::BaseAction
  self.name = 'Generate Monthly Report'
  self.standalone = true  # Can run without selecting records
  self.visible = -> { view.index? }  # Only show on index page

  def fields
    field :month, as: :date, required: true
    field :include_archived, as: :boolean, default: false
  end

  def handle(query:, fields:, current_user:, **args)
    month = fields[:month]
    include_archived = fields[:include_archived]

    # Generate report
    report = ReportGenerator.new(
      month: month,
      include_archived: include_archived
    ).generate

    # Return download link
    succeed "Report generated successfully."
    download report.file_url, filename: "report-#{month}.pdf"
  end
end

Conditional Visibility

class Avo::Actions::SoftDelete < Avo::BaseAction
  self.name = 'Soft Delete'
  self.message = 'This will soft delete the selected records.'
  self.confirm_button_label = 'Soft Delete'
  self.cancel_button_label = 'Cancel'

  # Only show for models with SoftDeletable concern
  self.visible = -> {
    # Check if model includes the concern
    return false unless resource.model_class.included_modules.include?(SoftDeletable)

    # On show page, hide if already deleted
    if view.show?
      return false if record.deleted?
    end

    true
  }

  def handle(query:, fields:, current_user:, resource:, **args)
    deleted_count = 0

    query.each do |record|
      next if record.deleted?

      record.soft_delete
      deleted_count += 1

      # HIPAA audit logging
      HipaaAuditLog.create(
        user: current_user,
        action_type: 'soft_delete',
        auditable: record,
        details: { performed_via: 'avo_admin' }
      )
    end

    succeed "Soft deleted #{deleted_count} record(s)."
  end
end

Action Response Types

def handle(query:, fields:, current_user:, **args)
  # Success message
  succeed 'Operation completed successfully.'

  # Error message
  error 'Something went wrong.'

  # Warning message
  warn 'Completed with warnings.'

  # Redirect to URL
  redirect_to '/admin/reports'

  # Download file
  download '/path/to/file.pdf', filename: 'report.pdf'

  # Reload the page
  reload

  # Keep modal open (don't close)
  keep_modal_open

  # Silent (no message)
  silent
end

FILTERS

Filters allow users to narrow down displayed records.

Select Filter

# app/avo/filters/status_filter.rb
class Avo::Filters::StatusFilter < Avo::Filters::SelectFilter
  self.name = 'Status'

  def apply(request, query, value)
    case value
    when 'active'
      query.where(status: :active)
    when 'inactive'
      query.where(status: :inactive)
    when 'all'
      query  # No filter
    else
      query
    end
  end

  def options
    {
      'Active' => 'active',
      'Inactive' => 'inactive',
      'All' => 'all'
    }
  end

  # Default value
  def default
    'active'
  end
end

Boolean Filter

class Avo::Filters::ArchivedFilter < Avo::Filters::BooleanFilter
  self.name = 'Include Archived'

  def apply(request, query, value)
    if value['archived']
      query.unscoped  # Include all records
    else
      query  # Use default scope (excludes archived)
    end
  end

  def options
    {
      archived: 'Show archived records'
    }
  end
end

Text Filter

class Avo::Filters::EmailDomainFilter < Avo::Filters::TextFilter
  self.name = 'Email Domain'
  self.button_label = 'Filter by domain'

  def apply(request, query, value)
    query.where('email LIKE ?', "%@#{value}")
  end
end

Conditional Filter Visibility

class Avo::Filters::SoftDeleteStatusFilter < Avo::Filters::SelectFilter
  self.name = 'Record Status'

  # Only show for models with SoftDeletable concern
  self.visible = -> {
    resource.model_class.included_modules.include?(SoftDeletable)
  }

  def apply(request, query, value)
    case value
    when 'active'
      query.where(deleted_at: nil)
    when 'deleted'
      query.where.not(deleted_at: nil)
    when 'all'
      query.unscope(where: :deleted_at)
    else
      query
    end
  end

  def options
    {
      'Active Only' => 'active',
      'Deleted Only' => 'deleted',
      'All Records' => 'all'
    }
  end

  def default
    'active'
  end
end

DASHBOARDS & CARDS

Dashboards display metrics and visualizations.

Dashboard Structure

# app/avo/dashboards/platform_overview.rb
class Avo::Dashboards::PlatformOverview < Avo::Dashboards::BaseDashboard
  self.id = 'platform_overview'
  self.name = 'Platform Overview'
  self.description = 'Platform-wide metrics and health'
  self.grid_cols = 4  # Number of columns

  def cards
    # Organization metrics section
    divider label: 'Organizations'
    card Avo::Cards::TotalOrganizations, cols: 1
    card Avo::Cards::ActiveOrganizations, cols: 1
    card Avo::Cards::TrialOrganizations, cols: 1
    card Avo::Cards::SuspendedOrganizations, cols: 1

    # Users section
    divider label: 'Users & Activity'
    card Avo::Cards::TotalUsers, cols: 2
    card Avo::Cards::TotalAppointments, cols: 2

    # Revenue section
    divider label: 'Platform Revenue'
    card Avo::Cards::PaidRevenue, cols: 1
    card Avo::Cards::PendingRevenue, cols: 1
    card Avo::Cards::FailedInvoices, cols: 1
    card Avo::Cards::RevenueThisMonth, cols: 1
  end
end

Metric Card

# app/avo/cards/total_organizations.rb
class Avo::Cards::TotalOrganizations < Avo::Cards::MetricCard
  self.id = 'total_organizations'
  self.label = 'Total Organizations'
  self.suffix = 'orgs'
  # self.prefix = '$'  # For currency
  self.cols = 1

  def query
    # Use caching for performance
    count = Rails.cache.fetch('platform_admin/total_orgs', expires_in: 5.minutes) do
      # Access data outside tenant context for platform-wide metrics
      ActsAsTenant.without_tenant do
        Organization.count
      end
    end

    result count
  end
end

Currency Metric Card

class Avo::Cards::PaidRevenue < Avo::Cards::MetricCard
  self.id = 'paid_revenue'
  self.label = 'Paid Revenue'
  self.prefix = '$'
  self.cols = 1

  # Format cents to dollars
  self.format = -> {
    (value / 100.0).round(2)
  }

  def query
    revenue = Rails.cache.fetch('platform_admin/paid_revenue', expires_in: 5.minutes) do
      ActsAsTenant.without_tenant do
        PlatformInvoice.where(status: :paid).sum(:total_cents)
      end
    end

    result revenue
  end
end

Chartkick Card (Charts)

class Avo::Cards::MonthlySignups < Avo::Cards::ChartkickCard
  self.id = 'monthly_signups'
  self.label = 'Monthly Signups'
  self.chart_type = :line_chart  # :area_chart, :bar_chart, :column_chart, :pie_chart
  self.cols = 2
  self.rows = 2

  def query
    data = Rails.cache.fetch('platform_admin/monthly_signups', expires_in: 1.hour) do
      ActsAsTenant.without_tenant do
        Organization
          .where('created_at > ?', 12.months.ago)
          .group_by_month(:created_at)
          .count
      end
    end

    result data
  end
end

Partial Card (Custom HTML)

# app/avo/cards/recent_activity.rb
class Avo::Cards::RecentActivity < Avo::Cards::PartialCard
  self.id = 'recent_activity'
  self.label = 'Recent Activity'
  self.cols = 2
  self.rows = 2
  self.partial = 'avo/cards/recent_activity'
end
<%# app/views/avo/cards/_recent_activity.html.erb %>
<div class="p-4">
  <% @activities = HipaaAuditLog.order(created_at: :desc).limit(10) %>
  <ul class="space-y-2">
    <% @activities.each do |activity| %>
      <li class="text-sm">
        <span class="font-medium"><%= activity.user&.email %></span>
        <span class="text-gray-500"><%= activity.action_type %></span>
        <span class="text-gray-400"><%= time_ago_in_words(activity.created_at) %> ago</span>
      </li>
    <% end %>
  </ul>
</div>

AUTHORIZATION

Use Pundit policies for authorization.

Configuration

# config/initializers/avo.rb
Avo.configure do |config|
  config.root_path = '/admin'
  config.current_user_method = :current_user
  config.authorization_client = :pundit

  # Custom authentication guard
  config.authenticate = -> {
    return redirect_to new_user_session_path unless current_user

    # Platform admin check (outside tenant context)
    is_platform_admin = ActsAsTenant.without_tenant do
      current_user.has_role?(:platform_admin, Organization.find_by(name: 'Platform'))
    end

    unless is_platform_admin
      flash[:alert] = 'Access denied. Platform admin required.'
      redirect_to root_path
    end
  }
end

Resource-Level Authorization

Avo automatically checks Pundit policies:

# app/policies/client_policy.rb
class ClientPolicy < ApplicationPolicy
  def index?
    user.has_role?(:platform_admin) || user.has_role?(:admin, user.organization)
  end

  def show?
    index?
  end

  def create?
    user.has_role?(:admin, user.organization)
  end

  def update?
    create?
  end

  def destroy?
    user.has_role?(:platform_admin)
  end

  class Scope < Scope
    def resolve
      if user.has_role?(:platform_admin)
        scope.all
      else
        scope.where(organization: user.organization)
      end
    end
  end
end

Field-Level Authorization

field :amount, as: :number,
  disabled: -> {
    !@resource.authorization.authorize_action(:update_amount?, raise_exception: false)
  }
# In policy
def update_amount?
  user.has_role?(:admin)
end

Action Authorization

class Avo::Actions::DeletePermanently < Avo::BaseAction
  self.name = 'Delete Permanently'

  self.authorize = -> {
    current_user.has_role?(:platform_admin)
  }

  # Or check via policy
  self.visible = -> {
    Pundit.policy(current_user, resource.model_class).destroy?
  }
end

MULTI-TENANCY

Handle multi-tenant data access properly.

Platform-Wide Queries

# In cards/dashboards, access all data:
def query
  ActsAsTenant.without_tenant do
    Organization.count
  end
end

Scoped Queries

# In resources, data is automatically scoped by Pundit policies
class Scope < Scope
  def resolve
    if user.has_role?(:platform_admin)
      ActsAsTenant.without_tenant { scope.all }
    else
      scope  # Automatically scoped to current tenant
    end
  end
end

Cache Keys

Use descriptive, scoped cache keys:

Rails.cache.fetch('platform_admin/total_users', expires_in: 5.minutes) do
  # ...
end

Rails.cache.fetch("org_#{organization.id}/active_clients", expires_in: 2.minutes) do
  # ...
end

CONTROLLER STUBS

Avo generates controller stubs for customization:

# app/controllers/avo/clients_controller.rb
class Avo::ClientsController < Avo::ResourcesController
  # Override actions if needed
  def create
    # Custom create logic
    super
  end

  private

  # Add custom authorization
  def authorize_resource
    authorize @record, policy_class: ClientPolicy
  end
end

ROUTING

Basic Mounting

# config/routes.rb
Rails.application.routes.draw do
  # Mount Avo on specific subdomain
  constraints subdomain: ['www', nil, ''] do
    mount Avo::Engine, at: Avo.configuration.root_path
  end

  # Redirect from org subdomains to www admin
  constraints ->(request) {
    request.subdomain.present? && !['www', ''].include?(request.subdomain)
  } do
    get '/admin', to: redirect { |_params, request|
      "#{request.protocol}www.#{request.domain}#{request.port_string}/admin"
    }
    get '/admin/*path', to: redirect { |params, request|
      "#{request.protocol}www.#{request.domain}#{request.port_string}/admin/#{params[:path]}"
    }
  end
end

BEST PRACTICES

1. Resource Organization

  • One resource per file
  • Group related fields with panels
  • Use sidebar for associations
  • Add meaningful search configuration

2. Performance

  • Always set self.includes for eager loading
  • Cache dashboard card queries
  • Use build_stubbed in tests when possible
  • Index frequently filtered columns

3. Security

  • Use Pundit policies for all resources
  • Implement field-level authorization
  • Audit sensitive data access
  • Validate inputs in actions

4. User Experience

  • Use descriptive field names
  • Add help text for complex fields
  • Group related fields logically
  • Provide meaningful action messages

5. Code Quality

  • Follow existing patterns in codebase
  • Write tests for custom actions
  • Document complex logic
  • Use concerns for shared behavior

COMMON PATTERNS

Soft Delete Support (Complete Pattern)

This project implements a comprehensive soft deletion pattern for Avo that includes:

  • Bypassing default scopes to show deleted records
  • Dynamic row controls (delete vs restore buttons)
  • Status filtering and badges
  • HIPAA audit logging
  • Intercepting native delete to perform soft delete

1. Model-Level Setup

The SoftDeletable concern provides core functionality:

# app/models/concerns/soft_deletable.rb
module SoftDeletable
  extend ActiveSupport::Concern

  included do
    scope :active, -> { where(deleted_at: nil) }
    scope :deleted, -> { where.not(deleted_at: nil) }
    default_scope { active }  # Automatically filters to non-deleted
  end

  def soft_delete
    update(deleted_at: Time.current)
  end

  def restore
    update(deleted_at: nil)
  end

  def deleted?
    deleted_at.present?
  end
end

2. Resource Configuration (Critical Query Bypasses)

The resource must use unscoped to bypass the default_scope:

# app/avo/resources/user.rb
class Avo::Resources::User < Avo::BaseResource
  self.title = :email

  # BYPASS 1: Index query bypasses default_scope to show all records
  # The filter handles active/deleted filtering
  self.index_query = -> {
    ActsAsTenant.without_tenant do
      query.unscoped
    end
  }

  # BYPASS 2: Search uses unscoped to find soft-deleted records
  self.search = {
    query: -> {
      ActsAsTenant.without_tenant do
        query.unscoped.joins(:organization)
          .ransack(
            email_cont: params[:q],
            first_name_cont: params[:q],
            last_name_cont: params[:q],
            m: "or"
          ).result
      end
    }
  }

  # BYPASS 3: Find method for show/edit/actions (allows restoring deleted records)
  self.find_record_method = -> {
    ActsAsTenant.without_tenant do
      query.unscoped.find(id)
    end
  }

  def fields
    field :id, as: :id

    # Status badge - visually displays Active/Deleted
    field :status, as: :badge,
      options: { success: ["Active"], danger: ["Deleted"] },
      hide_on: [:forms], sortable: false do
      record.deleted? ? "Deleted" : "Active"
    end

    field :email, as: :text, link_to_record: true
    field :deleted_at, as: :date_time, sortable: true, readonly: true, hide_on: [:forms]
  end

  def filters
    filter Avo::Filters::SoftDeleteStatusFilter
  end

  def actions
    action Avo::Actions::Restore
  end

  # Dynamic row controls: swap delete button for restore button
  def render_row_controls(item:)
    controls = [
      Avo::Resources::Controls::ShowButton.new(item: item),
      Avo::Resources::Controls::EditButton.new(item: item)
    ]

    if record.deleted?
      controls << Avo::Resources::Controls::RestoreButton.new(
        record: record,
        resource: self
      )
    else
      controls << Avo::Resources::Controls::DeleteButton.new(item: item)
    end

    controls
  end
end

3. Soft Delete Status Filter

# app/avo/filters/soft_delete_status_filter.rb
class Avo::Filters::SoftDeleteStatusFilter < Avo::Filters::SelectFilter
  self.name = "Status"

  # Only visible for models with SoftDeletable concern
  self.visible = -> { resource.model_class.include?(SoftDeletable) }

  def options
    {
      Active: "active",
      Deleted: "deleted",
      All: "all"
    }
  end

  # Default to showing active records (matches default_scope behavior)
  def default
    "active"
  end

  def apply(_request, query, value)
    case value
    when "active"
      query.where(deleted_at: nil)
    when "deleted"
      query.where.not(deleted_at: nil)
    when "all"
      query  # Already unscoped via index_query
    else
      query
    end
  end
end

4. Restore Action

# app/avo/actions/restore.rb
class Avo::Actions::Restore < Avo::BaseAction
  self.name = "Restore"
  self.message = "Are you sure you want to restore the selected record(s)?"
  self.confirm_button_label = "Restore"
  self.cancel_button_label = "Cancel"

  self.visible = -> do
    return false unless resource.model_class.include?(SoftDeletable)

    if view == :show
      resource.record.deleted?  # Only show on deleted records in show view
    else
      true  # Show in bulk on index
    end
  end

  def handle(query:, fields:, current_user:, **args)
    restored_count = 0

    query.each do |record|
      next unless record.deleted?

      record.restore
      restored_count += 1

      HipaaAuditLog.log(
        auditable: record,
        action_type: "restore",
        user: current_user
      )
    end

    succeed "Successfully restored #{restored_count} record(s)"
  end
end

5. Controller Concern (Intercept Native Delete)

This concern converts Avo's native delete button to perform soft delete:

# app/avo/concerns/soft_deletable_resource.rb
module SoftDeletableResource
  extend ActiveSupport::Concern

  private

  # Override Avo's destroy_record_action to perform soft delete
  def destroy_record_action
    if @record.respond_to?(:soft_delete)
      @record.soft_delete

      HipaaAuditLog.log(
        auditable: @record,
        action_type: 'soft_delete',
        user: _current_user
      )

      true
    else
      super  # Fallback to standard destroy
    end
  end

  def destroy_success_message
    if @record.respond_to?(:soft_delete)
      t('avo.resource_soft_deleted', resource_name: @resource.name)
    else
      super
    end
  end
end

Include in the controller:

# app/controllers/avo/users_controller.rb
class Avo::UsersController < Avo::ResourcesController
  include SoftDeletableResource
end

6. Custom RestoreButton Control

Define in the Avo initializer:

# config/initializers/avo.rb
Rails.application.config.to_prepare do
  unless Avo::Resources::Controls.const_defined?(:RestoreButton)
    Avo::Resources::Controls::RestoreButton = Class.new(Avo::Resources::Controls::BaseControl) do
      attr_reader :record, :resource

      def initialize(record:, resource:, **args)
        super(**args)
        @record = record
        @resource = resource
        @label = args[:label] || "Restore"
        @title = args[:title] || "Restore #{resource.name}".humanize
      end

      def action_path
        action_id = CGI.escape("Avo::Actions::Restore")
        "/admin/resources/#{resource.route_key}/#{record.to_param}/actions?action_id=#{action_id}"
      end
    end
  end

  # Custom rendering method for the RestoreButton
  Avo::Index::ResourceControlsComponent.class_eval do
    def render_restore_button(control)
      link_to control.action_path,
        class: "flex items-center",
        title: control.title,
        aria: { label: control.title },
        data: {
          turbo_frame: "modal_frame",
          tippy: "tooltip"
        } do
        helpers.content_tag(:svg, class: "text-green-600 h-6 hover:text-green-700",
          fill: "none", viewBox: "0 0 24 24", "stroke-width": "1.5", stroke: "currentColor") do
          helpers.content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round",
            d: "M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3")
        end
      end
    end
  end
end

Summary: How It All Works Together

  1. User clicks Delete on active record → SoftDeletableResource intercepts → calls soft_delete → record gets deleted_at timestamp
  2. User visits index → index_query bypasses default scope → filter defaults to "Active" → deleted records hidden
  3. User selects "Deleted" filter → sees only soft-deleted records → delete button replaced with restore button
  4. User clicks Restore → Avo::Actions::Restore runs → clears deleted_at → record visible in "Active" filter
  5. All actions logged → HIPAA audit trail for compliance

Currency Display

field :amount_cents, as: :number,
  name: 'Amount',
  format_using: -> { value.present? ? "$#{(value / 100.0).round(2)}" : '-' },
  sortable: true

Status Badges

field :status, as: :badge,
  map: {
    draft: :info,
    pending: :warning,
    processing: :warning,
    paid: :success,
    completed: :success,
    failed: :danger,
    cancelled: :secondary
  }

Searchable Associations

field :organization, as: :belongs_to,
  searchable: true,
  sortable: true

Readonly System Fields

field :id, as: :id
field :created_at, as: :datetime, sortable: true, readonly: true
field :updated_at, as: :datetime, readonly: true

EXECUTION STRATEGY

When building Avo admin features:

  1. Understand the requirement - What data needs to be managed?
  2. Check existing patterns - Look at similar resources in codebase
  3. Plan the resource - Fields, associations, actions, filters
  4. Implement incrementally:
    • Create resource with basic fields
    • Add associations and panels
    • Add actions and filters
    • Add authorization
    • Add audit logging if needed
  5. Test thoroughly - Verify authorization and data access
  6. Document - Add help text and descriptions

TESTING AVO RESOURCES

# spec/avo/resources/client_resource_spec.rb
require 'rails_helper'

RSpec.describe Avo::Resources::Client do
  let(:resource) { described_class.new }

  describe 'configuration' do
    it 'has correct title' do
      expect(described_class.title).to eq(:full_name)
    end

    it 'includes required associations' do
      expect(described_class.includes).to include(:organization)
    end
  end

  describe 'fields' do
    let(:fields) { resource.fields }

    it 'includes expected fields' do
      field_names = fields.map { |f| f.id.to_s }
      expect(field_names).to include('id', 'first_name', 'last_name', 'email')
    end
  end
end

Remember: Avo is designed to be configuration-driven. Use its built-in features before writing custom code. Keep resources focused and well-organized, and always implement proper authorization.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.