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.
In this repository75 other files it gives its agents.
- acceptance-criteriaSkill
- architect-guideSkill
- architect-reviewSkill
- audit-fixerSkill
- backend-services-expertSkill
- bug-hunt-allSkill
- bug-hunt-fixSkill
- bug-huntSkill
- build-feature-remoteSkill
- build-featureSkill
- codeSkill
- component-testSkill
- controller-expertSkill
- create-qa-documentSkill
- debugSkill
- devops-expertSkill
- execute-qaSkill
- feature-qaSkill
- fix-bug-indexSkill
- fix-honeybadgerSkill
- fix-spec-bugsSkill
- fix-system-testSkill
- frontend-expertSkill
- frontend-reviewSkill
- full-qa-fixSkill
- full-qaSkill
- full-reviewSkill
- green-ciSkill
- group-issuesSkill
- honeybadger-auditSkill
- ideateSkill
- impact-assessmentSkill
- improveSkill
- merge-mainSkill
- model-expertSkill
- model-testSkill
- native-bug-hunt-fixSkill
- native-bug-huntSkill
- page-speedSkill
- plan-questionsSkill
- planSkill
- plan-system-testsSkill
- policy-testSkill
- prdSkill
- prd-worktreeSkill
- product-questionSkill
- product-question-tldrSkill
- qa-branchSkill
- qa-testerSkill
- refactor-sweepSkill
- resolve-honeybadgerSkill
- resolve-issueSkill
- resolve-issuesSkill
- review-acceptance-criteriaSkill
- review-fixesSkill
- review-railsSkill
- rspec-test-expertSkill
- scale-reviewSkill
- service-testSkill
- settle-pr-reviewSkill
- The first 60 shown.
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
- Distinctive: line 13
Keep it simple: - Use Avo's built-in features before custom solutions
- Distinctive: line 15
Security first: - Always implement proper authorization
- Distinctive: line 18
Community license: - Only use features available in the free tier
- Distinctive: line 750
Use Pundit policies for authorization.
- Distinctive: line 884
Use descriptive, scoped cache keys:
- Distinctive: line 956
Use sidebar for associations
- Distinctive: line 957
Add meaningful search configuration
- Very specific: line 961
Always set self.includes for eager loading
- Very specific: line 963
Use build_stubbed in tests when possible
- Distinctive: line 968
Use Pundit policies for all resources
- Distinctive: line 975
Use descriptive field names
- Distinctive: line 976
Add help text for complex fields
- Distinctive: line 982
Follow existing patterns in codebase
- Distinctive: line 983
Write tests for custom actions
- Distinctive: line 985
Use concerns for shared behavior
- Distinctive: line 1337
When building Avo admin features:
- Distinctive: line 1340
Check existing patterns: - Look at similar resources in codebase
- Distinctive: line 1343
Create resource with basic fields
- Distinctive: line 1344
Add associations and panels
- Distinctive: line 1345
Add actions and filters
- Distinctive: line 1347
Add audit logging if needed
- Distinctive: line 1348
Test thoroughly: - Verify authorization and data access
- Distinctive: line 1349
Document: - Add help text and descriptions
≈ “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.
Files
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
- Keep it simple - Use Avo's built-in features before custom solutions
- Follow conventions - Match existing patterns in the codebase
- Security first - Always implement proper authorization
- Multi-tenant aware - Respect tenant boundaries in all queries
- HIPAA compliance - Log sensitive data access when required
- 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: truefor 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 configurationRESOURCES
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
endField 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 onlySelection 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: :tagsAssociation 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_oneFile Fields:
field :avatar, as: :file # Single file
field :attachments, as: :files # Multiple files
# Note: direct_upload requires Pro licenseSpecial 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 fieldCustom 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
}
endPanel 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
endACTIONS
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
endAction 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
endStandalone 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
endConditional 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
endAction 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
endFILTERS
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
endBoolean 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
endText 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
endConditional 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
endDASHBOARDS & 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
endMetric 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
endCurrency 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
endChartkick 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
endPartial 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
}
endResource-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
endField-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)
endAction 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?
}
endMULTI-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
endScoped 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
endCache 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
# ...
endCONTROLLER 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
endROUTING
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
endBEST 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.includesfor eager loading - Cache dashboard card queries
- Use
build_stubbedin 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
end2. 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
end3. 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
end4. 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
end5. 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
endInclude in the controller:
# app/controllers/avo/users_controller.rb
class Avo::UsersController < Avo::ResourcesController
include SoftDeletableResource
end6. 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
endSummary: How It All Works Together
- User clicks Delete on active record →
SoftDeletableResourceintercepts → callssoft_delete→ record getsdeleted_attimestamp - User visits index →
index_querybypasses default scope → filter defaults to "Active" → deleted records hidden - User selects "Deleted" filter → sees only soft-deleted records → delete button replaced with restore button
- User clicks Restore →
Avo::Actions::Restoreruns → clearsdeleted_at→ record visible in "Active" filter - 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: trueStatus 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: trueReadonly System Fields
field :id, as: :id
field :created_at, as: :datetime, sortable: true, readonly: true
field :updated_at, as: :datetime, readonly: trueEXECUTION STRATEGY
When building Avo admin features:
- Understand the requirement - What data needs to be managed?
- Check existing patterns - Look at similar resources in codebase
- Plan the resource - Fields, associations, actions, filters
- Implement incrementally:
- Create resource with basic fields
- Add associations and panels
- Add actions and filters
- Add authorization
- Add audit logging if needed
- Test thoroughly - Verify authorization and data access
- 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
endRemember: 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.
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
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.

