Resources

Resources are defined using Admin::Base::Resource.

AdminSuite loads resource definition files from AdminSuite.config.resource_globs (defaults include config/admin_suite/resources/*.rb and app/admin/resources/*.rb).

Create a resource

A resource is a Ruby class under Admin::Resources ending in Resource.

Example:

# config/admin_suite/resources/user.rb
module Admin
  module Resources
    class UserResource < Admin::Base::Resource
      model ::User
      portal :ops
      section :accounts

      nav label: "Users", icon: "users", order: 10

      index do
        searchable :email, :name
        sortable :created_at, :email, default: :created_at, direction: :desc
        paginate 50

        columns do
          column :id
          column :email
          column :created_at
          column :admin, type: :toggle, toggle_field: :admin, header: "Admin?"
          column :status, type: :label, label_color: ->(u) { u.active? ? :emerald : :slate }, label_size: :sm
        end

        filters do
          filter :search, type: :text, placeholder: "Search users..."
          filter :status, type: :select, options: [["Active", "active"], ["Inactive", "inactive"]]
        end

        stats do
          stat :total, -> { User.count }, color: :slate
          stat :new_24h, -> { User.where("created_at > ?", 24.hours.ago).count }, color: :emerald
        end
      end

      form do
        section "Basics", description: "Core account fields" do
          field :email, type: :email, required: true
          field :name, required: true
        end

        row cols: 2 do
          field :admin, type: :toggle, help: "Grants access to internal tools."
          field :status, type: :select, collection: [["Active", "active"], ["Inactive", "inactive"]]
        end
      end

      show do
        main do
          panel :details, title: "User details", fields: %i[email name status created_at]
          panel :activity, title: "Activity", render: :custom_activity_timeline
        end

        sidebar do
          panel :summary, title: "Summary", fields: %i[id admin]
        end
      end

      actions do
        action :reset_password, label: "Reset password", icon: "key", confirm: "Send reset email?"
      end
    end
  end
end

Core DSL

Model

model ::User
portal :ops
section :accounts

These determine:

  • URL: /:portal/:resource_name
  • Sidebar placement: portal group → section group → resource link
nav label: "Users", icon: "users", order: 10

Also available as convenience setters:

label "Users"
icon "users"
order 10

Index DSL (index do ... end)

searchable :name, :email

Search uses ILIKE across the configured fields.

Sort

sortable :created_at, :email, default: :created_at, direction: :desc

Pagination

paginate 25

paginate(n) sets the resource’s index page size — Pagy respects it as of 0.5.0. In every earlier release this value was silently ignored (see Fixed: paginate(n) was ignored below); every index page previously served Pagy’s own default of 20 rows regardless of what was declared here. Upgrading to 0.5.0 may change the row counts and pagination boundaries a resource’s index page shows.

Eager loading (includes:)

index do
  includes :company, :line_items
  # ...
end

Applies scope.includes(*args) to the index’s filtered/sorted/searched scope, before pagination, so eager loading covers exactly the rows actually rendered on the current page. Accepts one or more association names (Symbols or Strings); array arguments are flattened and nil entries are dropped.

Degrades safely rather than raising:

  • If the scope doesn’t respond to #includes at all (a non-ActiveRecord scope), the option is skipped silently — no warning, no error.
  • If #includes itself raises (e.g. a typo’d/renamed association name), the error is logged via Rails.logger.warn and the index renders normally, just without the eager loading — a bad includes: value degrades performance, not availability.

Columns

columns do
  column :email
  column :job_listings, ->(u) { u.job_listings.count }
  column :status, align: :center, class: "font-mono"
end

column options:

  • header: string (defaults to a humanized name)
  • class: css class(es) applied to the rendered <td>
  • align: :left, :center, or :right — emits the matching Tailwind text-alignment class on the <td>. Any other value (a typo, an unsupported Symbol) is silently ignored — no alignment class is added, and nothing is raised or interpolated into a fabricated class name.
  • render: custom render key (advanced)
  • type: :toggle or :label (special rendering)
  • toggle_field: field to flip when type: :toggle
  • label_color: color for type: :label (Symbol or Proc)
  • label_size: :sm/:md (or Proc)
  • sortable: boolean (reserved for future per-column sorting UI)

A column value that is nil — whether a plain scalar or a belongs_to association — renders as —, not a blank cell.

A column value that is an ActiveRecord::Base instance (e.g. a belongs_to association returned from a column proc) renders as a link to that record’s own admin show page when one is registered, falling back to plain text (never a raw #<Company:0x...> inspect string) when no resource is registered for that class, the record is unpersisted, or its display title raises.

Fixed: paginate(n) was ignored before 0.5.0

The controller called Pagy with items: n, but Pagy’s vars key is limit: — items: is an unknown key that Pagy silently ignores, so every resource’s paginate(n) value has been ignored since the gem’s first commit. This was a long-standing latent bug, not a 0.5.0 regression; it’s now fixed, and declared page sizes take effect.

Filters

filters do
  filter :status, type: :select, options: [["Active", "active"], ["Inactive", "inactive"]]
end

Filter options:

  • type: (default :text)
  • label:
  • placeholder:
  • options: / collection: (for select-like UI)
  • field: which model field to filter on (defaults to the filter name)
  • apply: Proc that receives the scope (advanced)

Stats

stats do
  stat :total, -> { User.count }, color: :slate
end

Form DSL (form do ... end)

form do
  field :name, required: true
  field :website, type: :url
end

See Fields for supported types and options.

AdminSuite also supports basic layout helpers in forms:

  • section "Title" do ... end
  • row cols: 2 do ... end

Show DSL (show do ... end)

Show is section-based. Sections can live in the main column or sidebar.

show do
  main do
    panel :details, title: "Details", fields: %i[name email]
    panel :related, title: "Projects", association: :projects, display: :table, columns: %i[id name status], paginate: true
  end

  sidebar do
    panel :meta, title: "Meta", fields: %i[id created_at updated_at]
  end
end

Panel options:

  • title: (defaults to a humanized name)
  • fields: array of field names to display
  • render: custom renderer key (see custom_renderers in Configuration)
  • association: association name to render (has_many, belongs_to, etc.)
  • display: :list (default), :table, or :cards for associations
  • columns: columns for association :table display
  • link_to: helper method name to build links for association items (optional)
  • paginate: boolean, enables pagination within the association section
  • per_page: items per page for association pagination
  • limit: max items (if not paginating)
  • collapsible: / collapsed: (reserved for future UI toggles)
  • hide_blank: boolean, default false — hides a fields: row entirely instead of rendering a label with an empty value. Works for both sidebar and main-column fields: panels. Only affects fields: panels — it has no effect on association: or render:-driven panels.
sidebar do
  panel :meta, title: "Meta", fields: %i[id internal_notes], hide_blank: true
end

The blank contract is stricter than .blank? — read this before relying on it. A value is hidden only when it is nil, or it responds to #empty? and #empty? is true ("", [], {}). It is deliberately NOT Rails’ #blank?:

Value Hidden under hide_blank: true?
nil Yes
"" Yes
[] / {} Yes
" " (whitespace-only) No — kept, because String#empty? is a length check (" ".empty? is false), not a content check
false No — kept, because FalseClass has no #empty? — and false is a real, meaningful rendered value (a “No” toggle), not an absence
0 / 0.0 No — kept, same reasoning — Integer/Float have no #empty?

If a field’s accessor raises, the row is currently rescued to nil and therefore also hidden under hide_blank: true — indistinguishable from a value that was never populated. (Without hide_blank, a raising accessor still shows its label with a bare —.) This is a known sharp edge, not a bug fix target for 0.5.0 — flagging it here since it’s non-obvious.

Actions DSL (actions do ... end)

actions do
  action :reindex, label: "Reindex", method: :post, confirm: "Reindex this record?"
end

See Actions for how actions execute and how to define handlers.

MCP exposure (0.6.0)

Use mcp false to exclude a resource from discovery and calls. MCP serializes only declared fields/association columns; see Admin MCP. The deprecated no-op exportable DSL method is removed; delete its calls before upgrading.