Administratum
A Rails admin engine where every adminable resource is one file in app/admin.
You get a Tailwind v4 CRUD UI with Devise login out of the box. When the defaults
stop fitting, you can replace any part of it: a field, a partial, a whole page, or
a JS island running Vue, React or anything else.
- One class per resource, and that class is a real controller.
- Index, show, new, edit and delete, with filters (Ransack) and pagination (Pagy).
- A field DSL with built-in types, plus your own types backed by a partial.
- Template overrides by file location:
app/views/admin/<resource>/edit.html.erb. - Nested resources, shallow or not.
- Custom member and collection actions, and buttons that trigger them.
- Framework-agnostic JS islands, mounted with props from ERB.
- Styling through design tokens. Use the bundled CSS, or build it with your own Tailwind v4.
Requires Rails 8, with Propshaft or Sprockets. With Sprockets, add //= link administratum.css and
//= link administratum.js to app/assets/config/manifest.js.
Install
gem "administratum", "~> 0.2"Switching from CodeArtifact or a git source
- Replace the
source "https://greyseal-…codeartifact…" doblock (or thegit:line) with the line above, then runbundle update administratum. - Drop the CodeArtifact token from the Kamal
builder.secrets,.kamal/secretsand theRUN --mount=type=secretin the Dockerfile. - Unset
bundle config local.administratumif you set it (add--globalif it was set there). - To work against a local checkout, temporarily use
gem "administratum", path: "../administratum". Don't commit it.
Setting up an app
bin/rails g administratum:install # initializer, migration, app/admin, mount at /administratum
bin/rails db:migrate
bin/rails administratum:create_user EMAIL=you@example.com
bin/rails g administratum:resource Post # app/admin/post_admin.rbAlongside ActiveAdmin
ActiveAdmin also uses app/admin, and removes it from Zeitwerk. Administratum skips that directory
with a warning in the log, so nothing breaks. Its resources then need a directory of their own, and
this initializer should ship in the same deploy as the Gemfile change:
# config/initializers/administratum.rb
Administratum.configure do |config|
config.resource_paths = ["app/admin_next"]
config.view_prefix = "admin_next" # templates in app/views/admin_next/<resource>/
endMount the engine away from ActiveAdmin's path with bin/rails g administratum:install --path=/admin_next.
Once ActiveAdmin is gone, move everything back to app/admin and drop both settings.
Resources
# app/admin/post_admin.rb
class PostAdmin < Administratum::Resource
model Post # inferred from the class name when omitted
title :title # display name of a record (default: title/name)
label "Article", plural: "Articles"
filters :title, :published, :category_id # default: the title attribute
filter :language, as: :select, collection: %w[en sv]
index sort: "created_at desc", per_page: 50, csv: true do
column :title
column :category # belongs_to: links to the record
column :published # booleans render as badges
column(:comments, sortable: false) { |post| post.comments.size } # runs in the view
column(:preview, csv: false) { |post| link_to "Preview", post } # left out of the CSV
end
form do
field :title, hint: "Shown in listings"
field :body, as: :textarea, rows: 8
field :published
field :category_id # inferred :belongs_to select
field :tag_ids # inferred :has_many_ids multi-select
field :status, as: :select, collection: -> { Post::STATUSES }
end
show do # default: id, form fields, timestamps
attribute :title
attribute(:words) { |post| post.body.to_s.split.size }
attribute :published_at, if: ->(post) { post.published? } # row hidden otherwise
end
permit :title, :body, :published, :category_id, tag_ids: [] # default: the form fields
scope { |posts| posts.includes(:category) } # every query starts here
scope :published # index tab, calls Post.published
actions except: :destroy # :index :show :new :edit :destroy
menu label: "Articles", priority: 10 # `menu false` hides it
endA resource class is a controller (Administratum::Resource < ActionController::Base
by default). Everything you'd do in a controller works: before_action, rescue_from,
helper, overriding resource_params or scoped_collection, and so on. In views, the
current record is @resource, the index records are @resources, and the parent of a
nested resource is @parent.
For a namespaced model (model ActsAsTaggableOn::Tag), Rails derives the route key and form
params from the full name: /acts_as_taggable_on_tags and params[:acts_as_taggable_on_tag].
Set route :tags for a shorter URL. The param key stays the same, so permit and
resource_params work as usual.
Resource files are loaded to draw routes, so the DSL never touches the database at class load. Types and defaults are resolved when a request is made.
Field types
| Type | Notes |
|---|---|
string |
Default for string columns |
textarea |
rows: |
number |
step:, min:, max:
|
boolean |
|
select |
collection: takes an array, a relation, or a lambda (optionally receiving the record); include_blank:; group_by:; searchable: (see below) |
multi_select |
Permitted as an array |
belongs_to |
Inferred for *_id belongs_to columns; takes the same options as select
|
has_many_ids |
Inferred for *_ids; permitted as an array |
date |
Text input for ISO dates (YYYY-MM-DD), whatever the browser locale |
datetime |
Text input for YYYY-MM-DD HH:MM, parsed in the app time zone |
email |
|
password |
|
hidden |
|
json |
Pretty-printed textarea |
island |
Rendered by a JS island (see below): island: "name", props: ->(record) { {...} }
|
Every field also takes these options:
| Option | Effect |
|---|---|
label: |
Overrides the label. Defaults to the attribute's human name, with tag_ids shown as "Tags". |
hint: |
Help text shown under the input and linked to it with aria-describedby. |
placeholder: |
Placeholder text. On selects it becomes the blank option's text. |
required: |
Shows or hides the required marker. Defaults to whether the model validates presence. |
input_html: {} |
Extra HTML attributes on the input. |
Long lists: grouping and search
group_by: sorts a select's options into <optgroup>s. It takes an attribute name or a lambda receiving each item:
field :task_template_id, collection: -> { TaskTemplate.includes(:task_group).order(:task_group_id, :name) },
group_by: :task_group # an association shows as its record titlesearchable: true turns a select or belongs_to field into a type-to-filter input. Typing filters across option and
group labels, ignoring case and accents. The arrow keys, Enter and Escape work as in a native select. Inside a side panel,
Escape closes the list first, then the panel. The same param is submitted, and without JS it's a plain <select>.
For lists too long to render, search on the server instead. The URL gets ?q= and returns [{ "id": 1, "label": "…", "group": "…" }]
(group is optional). Only the current value is rendered into the page. Without JS, the field can't be changed.
field :task_template_id, searchable: { url: -> { paths_for(TaskTemplateAdmin).action(:search) }, min: 2 }url: takes a string or a lambda (optionally receiving the record). min: is how many characters to type before
searching (default 2). For a select you build yourself, for example in a panel partial, add the data attributes:
<%= f.select :task_template_id, grouped_choices, {}, class: "adm-input", data: adm_combobox_data %>
<%= f.select :task_template_id, [], {}, class: "adm-input", data: adm_combobox_data(url: search_path, min: 3) %>Custom field partials get the same wiring by building their attributes with input_options(field, form:, class: "adm-input").
Register your own type with a partial:
# config/initializers/administratum.rb
config.field_types.register :color, partial: "admin/fields/color" # param: :scalar (default)
config.field_types.register :tag_list, partial: "admin/fields/tags", param: :array<%# app/views/admin/fields/_color.html.erb — locals: form, field, resource, value %>
<%= form.color_field field.name, class: "adm-input" %>The label, hint and errors wrapper is added for you. To restyle a built-in type,
shadow its partial at app/views/administratum/fields/_<type>.html.erb.
Index scopes
Named scopes become tabs above the index table, each with a record count:
class PostAdmin < Administratum::Resource
scope :all # Post.all
scope :published # Post.published
scope(:recent, label: "This week") { |posts| posts.where(created_at: 1.week.ago..) }
scope :archived, count: false # skip the count query
end- The first tab is active unless one says
default: true. The URL carries?scope=published. - Counts and results respect the current filters. Switching tabs keeps filters and sorting, and goes back to page 1.
- The CSV export follows the active tab.
- Labels default to
administratum.scopes.<name>in your locale, then to the humanized name. -
scopewith only a block (no name) still sets the base query for every request, not a tab. - In a custom
index.html.erb, render the tabs with<%= index_scope_tabs %>.
Template overrides
Templates are looked up in app/views/admin/<route_key>/ first, then in the engine.
To replace a page, add a file:
| File | Replaces |
|---|---|
index.html.erb |
The index page |
show.html.erb |
The show page |
new.html.erb / edit.html.erb
|
The whole page |
_form.html.erb |
Only the form, on both new and edit |
_table.html.erb / _attributes.html.erb
|
The index table / the show attribute list |
<custom_action>.html.erb |
A custom action's page |
You can also point form template: "admin/posts/editor" at a partial elsewhere.
To keep these templates somewhere other than app/views/admin (for example while an
older admin still lives there), set config.view_prefix = "admin_next".
Helpers available in templates:
<%= page_header title: "Write #{@resource.title}" do %>
<%= buttons_for(:show) %> <%# built-in plus custom buttons for a page %>
<% end %>
<%= administratum_form do |form| %> <%# form_with with the right URL and method %>
<%= render_fields form, except: [:body] %> <%# DSL fields %>
<%= render_field form, :body %> <%# a single DSL field %>
<%= render_field form, :notes, as: :textarea %> <%# an ad-hoc field %>
<%= adm_date_field form, :ends_on %> <%# ISO date input; also adm_datetime_field %>
<%= form.submit class: "adm-btn adm-btn-primary" %>
<% end %>Path helpers:
-
collection_path,new_resource_path,resource_path(record),edit_resource_path(record) -
resource_form_path(record),action_path(:write, record),parent_path -
paths_for(OtherAdmin).index, for paths to another resource
Layout helpers: card(title:) { }, island, record_title, format_value. Reordering: reorder_form,
reorder_controls, reorder_save_button (see below). Searchable selects: adm_combobox_data.
Nested resources
class CommentAdmin < Administratum::Resource
parent :post, shallow: true # association defaults to post.comments
end- Collection routes (index, new, create) nest under the parent:
/posts/1/comments. - Shallow member routes stand alone:
/comments/5/edit. Withoutshallow:, they also nest. - Queries go through the parent's association, so a child of another parent returns 404.
- Breadcrumbs include the parent.
- Nested resources are left out of the menu. Link to them with a button (below).
One level of nesting is supported.
Custom actions and buttons
class PostAdmin < Administratum::Resource
member_action :write # GET, renders admin/posts/write.html.erb
member_action :save_body, method: :patch
member_action :publish, method: :post
collection_action :export
collection_action :import, method: :post
button :write, on: %i[show row], style: :primary
button :publish, on: :show, confirm: "Publish?", if: ->(post) { !post.published? }
button :comments, on: :show, to: ->(post) { post_comments_path(post) } # plain link
button :import, on: :index, upload: ".json" # file upload panel
button :add_tag, on: :show, panel: "admin/posts/add_tag" # your partial in a side panel
def save_body
@resource.update!(body: params.require(:body))
render json: { body: @resource.body }
end
def publish
@resource.update!(published: true)
redirect_to resource_path, notice: "Published"
end
def export = send_data(Post.export_json, filename: "posts.json")
def import
Post.import_json(params.require(:file).read)
redirect_to collection_path
end
endButton options:
-
on:takes:index,:show,:new,:editor:row(one button per index row). -
style:takes:primary,:secondary,:dangeror:ghost. -
if:andto:lambdas run in the view and receive the record when they take an argument.
Non-GET buttons render as small forms, so no rails-ujs is needed.
A panel partial receives resource: and button:.
Ordering records
class ChapterAdmin < Administratum::Resource
parent :fantasy
reorderable # integer column, :position by default
endreorderable adds PATCH /fantasies/:fantasy_id/chapters/reorder, which takes positions[<id>]=<n>. Only records in the
resource's collection (here, the fantasy's chapters) move. Each submitted record goes to its number, and records left out keep
their order around it. Positions are then renumbered from 1 with update!, so validations and callbacks run. A unique index on
the position column would clash with the renumbering, and so can callbacks that renumber too (acts_as_list's, for example);
in that case write your own action. HTML requests redirect back; JSON requests get 204 No Content.
The index sorts by the column and, while it lists every record (one page, no filters narrowing it), adds drag handles and move up/down buttons with a "Save order" button. Without JS, each row has a position number field instead.
Anywhere else, such as cards on a parent's show page, use the helpers. The fields join the form through form="…", so
the list can hold its own forms (row buttons, for example):
<%= reorder_form id: "chapter-order", url: paths_for(ChapterAdmin).action(:reorder, @resource) %>
<div data-adm-sortable>
<% @resource.chapters.order(:position).each do |chapter| %>
<div class="adm-card" data-adm-sortable-item>
<%= reorder_controls chapter, form: "chapter-order" %> <%# column: :position, position: … %>
<%= chapter.title %>
</div>
<% end %>
</div>
<%= reorder_save_button form: "chapter-order" %>The items must be direct children of the data-adm-sortable element. To use the renumbering in your own action, call
reorder_records!(relation, params[:positions], column: :position) in a resource, or Administratum::Reorder.call anywhere.
Pages
A page is a screen that isn't CRUD on one model, like a report, a tool or a status page. It
lives in app/admin like a resource, gets a menu entry and a route, and is a controller:
# app/admin/reports_page.rb
class ReportsPage < Administratum::Page
label "Reports" # default: from the class name
menu priority: 5 # or `menu false`
page_action :refresh, method: :post # POST /administratum/reports/refresh
page_action :weekly # GET /administratum/reports/weekly
button :refresh, confirm: "Refresh?", style: :primary # buttons go in the page header
button :weekly
def index
@stats = Stats.compute
end
def refresh
Stats.refresh!
redirect_to page_path, notice: "Refreshed"
end
end<%# app/views/admin/reports/_content.html.erb: the default index renders the header, then this %>
<%= card title: "Posts" do %><%= @stats[:posts] %><% end %>- Templates live in
app/views/admin/<route_key>/. The route key comes from the class name (ReportsPageisreports), or set it withroute :stats. - Replace the whole page with
index.html.erb. A GETpage_actionwithout a method renders<name>.html.erb. - In templates and the class:
page_path,action_path(:refresh), andpage_class. - From elsewhere:
administratum.reports_page_pathandadministratum.refresh_reports_page_path. - Pages have the same sign-in, layout,
before_actionandhelpersupport as resources. - Pagy is included, so
@pagy, @rows = pagy(scope)and<%== pagy_nav(@pagy) %>work on pages too.
JS islands
An island is a mount point with JSON props. What runs inside it is up to you:
<%= island :post_writer, props: { body: @resource.body, saveUrl: action_path(:save_body) } %>// your admin JS, loaded through config.head
import { createApp } from "vue"
import PostWriter from "./PostWriter.vue"
window.Administratum.islands.register("post-writer", (el, props) => {
const app = createApp(PostWriter, props) // props.csrfToken is always present
app.mount(el)
return () => app.unmount() // teardown, called before Turbo swaps the page
})- Islands mount on
DOMContentLoadedandturbo:load, and never twice. - If your script can run before Administratum's, queue registrations:
(window.Administratum ||= { islands: { queue: [] } }).islands.queue.push([name, fn]). -
Administratum.csrfToken()is also available.
Load your own JS and CSS with any asset pipeline:
config.head = ->(view) { view.javascript_importmap_tags("admin") } # importmap
config.head = ->(view) { view.vite_javascript_tag("admin") } # vite_ruby
config.head_partial = "admin/head" # or a partialStyling
All colours are CSS variables (--color-adm-*), so theming never needs a rebuild:
config.theme = { primary: "#7c3aed", bg: "#fafafa" }
config.dark_theme = { primary: "#a78bfa" }The available tokens are listed in DESIGN.md.
Host Tailwind v4. If your app already builds Tailwind v4, compile Administratum into
your own build so tokens live in your @theme:
bin/rails administratum:tailwind:sync # writes app/assets/tailwind/administratum.gen.css (gitignore it)@import "tailwindcss";
@import "./administratum.gen.css";
@theme { --color-adm-primary: var(--color-brand); }config.stylesheet = :host # stop loading the bundled stylesheetOnce the generated file exists, the sync task runs before assets:precompile and vite:build.
Configuration
See the generated config/initializers/administratum.rb for every option. The main ones:
-
app_name,logo,dashboard -
parent_controller,helpers,per_page -
mailer_sender,manage_users -
head,head_partial,theme,stylesheet,field_types
Authentication
-
Accounts. Admins are
Administratum::Userrecords (tableadministratum_users) on a separate Devise scope,:administratum_user. They never collide with your app's own users. - Sign-out. Signing out of the admin leaves the app's session alone.
- Password reset. Built in. The reset form doesn't reveal whether an email exists.
-
Current admin. Available in views as
current_admin.
Development
bundle install
bin/rails test
bundle exec rubocop
bin/build-css # rebuild app/assets/builds/administratum.css after changing views or CSSThe dummy app in test/dummy exercises each extension point:
- custom field type, write page with an island, and export/import (posts)
- shallow nesting (comments) and non-shallow nesting (attachments)
- custom index template (categories) and custom form partial (tags)
Releasing
Releases go to rubygems.org.
- Bump
Administratum::VERSIONinlib/administratum/version.rb, runbundle installto updateGemfile.lock, and commit and push tomain. - In GitHub, run the Release workflow (Actions → Release → Run workflow).
The workflow builds the gem, tags vX.Y.Z, pushes the tag and pushes the gem. It uses rubygems.org
trusted publishing, so no API key is stored anywhere.
To release from your own machine instead, run bundle exec rake release with a clean working tree.
It asks for your rubygems.org login and an MFA code.
Host apps pick up the new version with bundle update administratum.