CrudComponents

AI disclaimer: This Gem is developed with the help of Claude. It is architected and reviewed by me, but the code is generated by Claude.

Declarative CRUD UI for ActiveRecord models, primarily for admin backends — rendered inside your app: your layout, your routes, your authorization, your styling, mixed freely with hand-written pages. Not an admin island with its own theme and navigation.

The core promise: zero configuration already works. A bare ActiveRecord model renders as a usable, filterable, sortable table in one line. Configuration only ever improves what you get — it never has to enable it, and removing any line of config falls back to a sensible default.

<%= crud_collection Book.all %>

That line gives you a table with type-appropriate cells, sortable headers, an inline filter row, and working URL params — ?genre=scifi&price_leq=20&sort=title&dir=desc filters and sorts it, with or without JavaScript. No controller code, no model code.

▶ Live demo: crud-components.zelenin.de — a browsable playground where every feature has a running page with the exact DSL behind it.

Installation

# Gemfile
gem 'crud_components'

Requires Rails >= 7.1 and Ruby >= 3.2.

Optionally, run the installer for an initializer for simple styling and optional Stimulus (JavaScript) controllers. (progressive enhancement; everything works without them):

bin/rails generate crud_components:install

Styling. The gem renders plain Bootstrap 5 utility classes (swap them in the class map), plus one tiny stylesheet for the column-picker dropdown. Load it pipeline-agnostically by dropping the helper in your layout <head>:

<%= crud_components_styles %>   <%# inlines the gem's CSS as a <style> tag, CSP-nonce aware %>

It needs no asset compilation, so it works the same under importmap, sprockets, propshaft or cssbundling. Hosts whose pipeline already serves engine assets can instead stylesheet_link_tag "crud_components".

The running example

A small bookstore, used throughout the docs:

class Book < ApplicationRecord
  belongs_to :publisher
  has_many :reviews
  has_and_belongs_to_many :authors
  has_one_attached :cover
  enum :genre, { fiction: 0, scifi: 1, nonfiction: 2 }
  # columns: title, subtitle, slug, blurb (text), price (decimal),
  #          pages (integer), published_on (date), active (boolean)
end

class Publisher < ApplicationRecord; has_many :books; end           # name, slug, founded_on
class Author    < ApplicationRecord; has_and_belongs_to_many :books; end  # name, email
class Review    < ApplicationRecord; belongs_to :book; end          # rating (int), body (text)

A table in one line

A derived collection table — cover thumbnails, a genre badge, currency, publisher links and a boolean icon, with header search, an inline filter row, sortable columns, row actions and a standalone filter sidebar

<%= crud_collection Book.all %>

With zero configuration, derived from what Rails already knows:

  • every column gets a type-appropriate cell and filter control (see the combination table);
  • genre renders as a badge and filters as a select of the enum keys;
  • associations become columns automatically — publisher (belongs_to) as a nil-safe link, authors / reviews (has_many / habtm) as a truncated list ("Tolkien, Lewis +3 more"); Active Storage attachments render by content type — images inline, a PDF/other file as a preview or an icon + filename (cover, has_many_attached sets, …);
  • headers come from human_attribute_name, so your existing model i18n applies;
  • filtering, search and sorting are read from the request params automatically — every state is a plain GET URL, shareable and bookmark-safe.

A single record and a standalone filter form work the same way:

<%= crud_record @book %>      <%# definition list, same cell renderers %>
<%= crud_filter Book %>       <%# labelled filter form, e.g. for a modal or sidebar %>

The rest of this README is a short "now I want to…" tour. Every step is optional; nothing below is required for any view above to work. Each section links to its in-depth reference.

The tour

Choose the columns

Include the DSL and curate with a fieldset — a named selection of fields:

class Book < ApplicationRecord
  include CrudComponents::Model
  crud_structure do
    fieldset :default, %i[cover title genre price publisher]   # [] is the off switch
  end
end

Curation happens only in fieldsets — declaring or customizing an attribute never adds or removes a column. → Views & fieldsets

Nicer cells

A record view as a definition list, each value rendered type-aware: currency, a boolean check, a genre badge, pretty-printed JSON and association links

crud_structure do
  # …
  attribute :price, as: :number, unit: '', digits: 2
  attribute :blurb, as: :markdown                          # uses a markdown gem if present
  attribute(:badge) { |book| tag.span(book.status, class: 'badge') }   # custom markup
  # …
end

Built-in renderers are surface-aware; soft-dependency ones (:markdown, :asciidoc, :json highlighting) use a gem when present. A name Rails doesn't know falls back to a public model method. → Fields & rendering

Filter / sort a computed column

Filtering and sorting happen in SQL, so computed fields declare how — facets live together in one block:

attribute :author_names do
  render { |book| book.authors.map(&:name).to_sentence }
  filter :authors                  # delegate to Author's search_in; or spell it out: filter authors: :name
  sort   { |scope, dir| scope.left_joins(:authors).order('authors.name' => dir) }
end

The search spec — the mini-language filter and search_in share — builds joins + parameterized, wildcard-escaped ILIKE from column/association names, nothing to sanitize. An association name alone delegates to that model's search_in. → Fields → the search spec

Columns the model doesn't know about

A table with the declared columns (title, price) followed by four user-defined columns — Shelf (string), Weight (number with a unit), Signed (a boolean check) and Acquired (date) — each sortable and with its own filter input

User-defined properties that live in a separate store (a definitions/values pair, JSONB, an API) become extra columns without touching the model — build a DynamicColumn per request and pass extra_columns:. A preload: lambda batch-loads the page (no N+1); add filter:/sort: to make it query like a real column. A column that is a domain object (a mail, a resource) can carry its own header link and bulk actions right in the <th> via header: / header_actions:. A header action declared on: :selection acts on the ticked rows × that column's object (it submits the shared select-form and makes the table selectable); on: :collection is a plain "for all rows" button.

<%= crud_collection @books, extra_columns: current_account.custom_property_columns %>
CrudComponents::DynamicColumn.new(:mail_42, label: 'Welcome mail',
  header: -> { link_to mail.name, mail },
  header_actions: [CrudComponents::Action.new(:send, on: :selection, icon: 'send', method: :post) { send_path(mail) }],
  preload: ->(records) {  }) { |record, loaded| loaded[record.id] }

Fields → dynamic columns · custom headers & column actions

Let users pick their columns

The same table with the column-picker gear open in the header — a checklist of every column the user may see (declared and dynamic), each toggleable, with Apply and Reset

Pass picker: true and a gear appears in the header row: users hide and reorder the columns they may see, grouped by source model (Pipedrive-style). It submits ?cols[]= to the same URL — like sort and filter — so it needs no endpoint, opens and works without JavaScript (native <details>), and is always intersected with the permitted set (a forged param can't reveal a gated column). By default (picked_columns: :auto) the gem reads the param for you — ephemeral, nothing stored. To persist, resolve the selection in your controller and pass it back as an Array; the gem then shows exactly that and never re-reads the param. It's also a standalone helper (crud_column_picker) you can drop above a crud_record detail view.

<%# ephemeral — the gem reads ?cols= %>
<%= crud_collection @books, picker: true %>

<%# persisted — controller stores the pick, view replays it (nil → :auto until the first pick) %>
<%= crud_collection @books, picker: true, picked_columns: current_user.book_columns %>

Views → column picker

Global search, slugs, display names

label :title              # default: name → title → first string column, else "Book #42"
identify_by :slug         # Use slug in the URLs
search_in :title, :subtitle, :publisher   # :publisher delegates to Publisher's search_in

label + identify_by + search_in are a model's identity — and they define how other models render, link and filter it through their associations. Declare Order's identity once and every belongs_to :order column gets it for free. → Fields → identity

Buttons / Actions

:new, :show, :edit, :destroy exist by default, self-disabling when a route doesn't resolve or the user isn't permitted. Declare more; the block is the path:

action :preview, icon: 'eye' do |book| book_preview_path(book) end
action :import, on: :collection do import_books_path end

Views → actions

Different tables on different pages

Two independent collections (Books and Reviews) side by side on one page, each with its own search, filters and sort, isolated by param_prefix

Use fieldsets to display different sets of fields in different places.

fieldset :index,      %i[cover title genre price publisher], actions: %i[preview edit destroy]
fieldset :playground, %i[cover title authors price published_on active]

crud_collection can be configured with different fieldsets and layouts.

<%= crud_collection @books, fieldset: :playground %>
<%= crud_collection @books, fieldset: :playground, layout: :cards %>   <%# layout is a separate axis %>

Filterability follows the fieldset — you can only filter and sort what you can see (a security property, below); filters: extends it when a surface wants more filters than columns. → Views & fieldsets

Only admins see purchase prices

We use CanCanCan by default for authorization.

attribute :purchase_price, if: :manage      # invisible AND unfilterable for non-managers

if: takes a symbol (can? sugar) or a lambda. A hidden field is hidden everywhere, including the query layer. → Security → permissions

If you want to use a different authorization library, use a lambda:

attribute :purchase_price, if: ->(record) { record.draft? }

Create and edit records

A derived edit form via simple_form: required marks and valid-state inputs, read-only fields as compact labels, and the habtm association as a removable-chip picker with an "add" dropdown

The gem uses simple_form to render forms. You can override the form rendering by overriding the _form.html.erb partial.

<%= crud_form @book %>          <%# edit if persisted, new if not %>

The gem does not own any controllers so it is your responsibility to handle the actions. But the gem provides a permit list for the controller you can use so the two can't drift:

params.require(:book).permit(*CrudComponents.permitted_attributes(Book, action: action_name.to_sym, ability: current_ability))

editable: adds a second permission dimension (you may see a field but not change it). → Forms

I have 50,000 books

A paginated table with the footer pager seated in the table's tfoot — "Page 3 of 15 · 120 total" on the left, a windowed page control on the right

Take the query into your own hands to paginate or compose with a scope:

@query = CrudComponents::Query.new(Book, params, ability: current_ability)
@books = @query.apply(Book.accessible_by(current_ability)).page(params[:page])  # kaminari
<%= crud_collection @books, query: @query %> # If you use kaminari, the pagination is rendered automatically

query: :static and param_prefix: handle several collections on one page. → Views → the manual query

Mental model

Rule zero: everything works untouched; declarations only improve. The field set is always all derived columns/associations plus declared computed fields. Curation is exclusively the job of fieldsets.

The whole DSL:

Declaration Role
attribute / attributes improve one/several fields (model-global)
render / filter / sort facets inside an attribute block — override exactly one derived behavior
label, identify_by identity: display name; the column URL params resolve
search_in the model's text identity (?q=, and what delegation expands to)
action buttons, per row or per collection
fieldset a named selection of fields and actions

Three ideas organize it:

  1. Derived vs. declared — per facet. Everything Rails knows is derived. A declared facet overrides that facet only; the rest stays derived.
  2. Definition vs. selection. attribute/action define once, model-globally; fieldset selects per surface. Never visibility flags on definitions.
  3. Identity composes through associations. label + identify_by + search_in define how other models render, link and search this one. Declare once, correct everywhere.

And one uniform query rule:

A URL param is applied iff it names a filterable field of the fieldset in play that the current user may see (or one of the reserved params q, sort, dir, page, per). Everything else never reaches SQL.

The combination table

Keyed by what a field is — with zero config, every row applies without declarations.

Field kind Rendered as Filter control Query behavior Sortable
string / text column text (truncated in tables) text input ILIKE %v%, wildcards escaped yes
numeric column number (as: :number for unit:/digits:) min–max pair _geq/_leq ranges + ?f=v exact; non-finite ignored yes
date / datetime column localized from–to pair whole-day-inclusive ranges + exact day yes
boolean column ✓/✗ icon (click to filter; if null) any/yes/no select (+ "not set" if nullable) cast & validated; invalid ignored; "not set" → IS NULL yes
enum i18n'd badge (click to filter; if null) select of enum keys (+ "not set" if nullable) validated against the enum; "not set" → IS NULL yes
json column pretty <pre> (rouge if present) no
Active Storage attachment image / preview / icon by content type no
belongs_to nil-safe link via target's label select valued by target's identify_by (text over search_in above select_limit) where(assoc: Target.where(identify_by => v)) v2
has_many / habtm "a, b +n more" links opt-in via filter facet no
public model method by value type
render block / as: as declared (unless filter facet) (unless sort facet) facet-gated

The bottom rows have empty query cells for a principled reason: filtering and sorting run in SQL, and a Ruby-computed value has no SQL meaning until a facet gives it one. Full per-flavor detail: Fields & rendering.

Security

The gem turns untrusted URL params into SQL safely; the guarantees are encoded as tests: permission-aware whitelisting (you can only filter/sort what you can see), no injection through sort/dir/values/specs, escaped LIKE wildcards, validated enum/boolean/numeric casts, identify_by-only belongs_to resolution. The full list, with how each is enforced, is in Security & the URL model.

No JavaScript required

Everything works with JS disabled: filtering and sorting are plain GET forms and links; the inline filter row binds to an external form via the HTML form attribute; forms are plain (simple_form) markup. Niceties layer on as one mechanism, not a fork: the markup is always the plain baseline, and Stimulus controllers enhance it in place via data-controller (no parallel template trees; framework choice lives in the class map). The gem ships four optional Stimulus controllers — crud-filter (strips empty inputs for clean URLs), crud-multiselect (a habtm <select multiple> → chips-list + "add" picker), crud-columns (drag-to-reorder + tidy ?cols= in the column picker) and crud-select ("select all" / per-group master checkbox + live count for selectable tables) — and depends on no JS. → Extending → progressive enhancement

Turbo Streams

Rows carry dom_ids and render independently, so the markup is morph- and stream-friendly out of the box. Add Rails' broadcasts_refreshes + a turbo_stream_from subscription and a collection updates live — only changed rows morph. The gem ships no streaming machinery; it just guarantees the markup a broadcast needs. (The dummy app's "Live" page demonstrates it.)

Styling

A custom cards layout: the same collection presenter rendered as a responsive card grid (cover pulled out, fields below), reusing the gem's search, filter sidebar and row actions

No CSS shipped; Bootstrap 5 class names by default, concentrated in one overridable class map (CrudComponents.configure { |c| c.css.table = … }). Swapping frameworks is a class map plus a few partials, never a fork. → Extending → styling

API reference

Helpers (the everyday API)

crud_collection(records, fieldset: nil, layout: :table, query: :auto, param_prefix: nil,
                actions: true, group_by: nil, extra_columns: nil, picker: false, picked_columns: :auto)
crud_record(record, fieldset: nil, actions: true, layout: :record, picked_columns: :auto)
crud_filter(model, fieldset: nil, query: nil, param_prefix: nil, layout: :filter)
crud_form(record, fieldset: nil, action: nil, url: nil, method: nil, layout: :form)
crud_actions(record_or_model, fieldset: nil)

crud_collection takes a relation (@books, Book.all, or an authorized scope), so your authorization runs in the controller, before the gem renders. crud_actions takes a record (→ row actions) or a model class (→ collection actions). query: is a tri-state: :auto (default) builds from params, a Query = manual (already filtered), :static = no filter row or sort links. The column picker is two knobs: picker: toggles the gear, picked_columns: seeds it (:auto reads ?cols=; an Array is verbatim, no param read).

DSL (inside crud_structure do … end)

label(method = nil, &block)
identify_by(column)                               # default :id
search_in(*spec) | search_in { |scope, q|  }     # default: own string/text columns
attribute(name, as: nil, form_as: nil, if: nil, editable: nil, **renderer_options, &block)
attributes(*names, **shared_options)
  # bare block (arity 1)  = render markup
  # facet block (arity 0) = render / filter / sort declarations
  # as:       — display renderer (the read-only / cell partial)
  # form_as:  — form-input partial (defaults to the field's type)
  # if:       — visibility (everywhere: column, filter, sort, form)
  # editable: — writability in forms (read-only when false / unpermitted)
action(name, icon:, title:, class:, confirm:, method:, on:, if:, &path_block)
fieldset(name, fields = :all, actions: nil, filters: nil)

The DSL is defined in lib/crud_components/builder.rb.

Raises at boot, each with a message that says what to do instead: crud_structure declared twice; a name that is no column/enum/association/method without a render facet; duplicate attribute/action/fieldset names; fieldsets referencing unknown fields/actions; fields named q/sort/dir/page/per; filter given both a spec and a block; an as: renderer with no matching partial or a missing gem.

Runtime

CrudComponents::Query.new(model, params, fieldset: :default, ability: nil, param_prefix: nil)
                                            # #apply(scope) → relation; #active?
CrudComponents.permitted_attributes(model, action: :update, ability: nil)  # strong-params list
CrudComponents.configure { |config|  }     # css/icon maps, select_limit, defaults

Documentation

Doc What
docs/fields.md Fields, renderers, facets, the search spec, identity, per-flavor behavior
docs/views.md The helpers, fieldsets, actions & route resolution, the manual query, pagination
docs/forms.md crud_form, the permit list, editable:, form controls, attachments
docs/security.md Permissions (if:/editable:), the whitelist, and the injection-safe URL model
docs/extending.md Partials/renderers/layouts, progressive enhancement, styling, i18n
docs/performance.md Eager-loading, the belongs_to select→text threshold, pagination

Dependencies

Gem Why
activerecord (>= 7.1) deriving structure from AR reflection is the whole point
activesupport (>= 7.1) core extensions, i18n
actionview (>= 7.1) rendering (partials, helpers)
simple_form (>= 5.0) form rendering — its wrappers make form markup match your design system across frameworks; deferring to it is less code and a better fit than reinventing wrappers. Light + ubiquitous

The first three ship with Rails; simple_form is the one deliberate third-party dependency, and only the form surface uses it. Everything else stays integration-by-detection: CanCanCan, turbo-rails, Stimulus, Bootstrap, kaminari, markdown and highlighting gems are feature-detected or documented integration points, never required. Development: minitest, rake, sqlite3, plus a minimal dummy Rails app.

Development

bundle install
bundle exec rake test

# the live playground (test/dummy doubles as a seeded demo app)
cd test/dummy && bin/rails db:schema:load db:seed && bin/rails server   # → :3000

ruby script/demo.rb               # query-side walkthrough in the terminal

The playground is living documentation — browse it live at crud-components.zelenin.de: its landing page indexes every feature, and each demo page carries a "How this page is built" panel with the exact DSL and ERB.

The playground landing page: a feature index grouped into Basics, Identity & associations, Power features and Integration — each card links to a running demo and shows the one line of code behind it

Tests mirror the design: dsl_validation_test.rb for every raising combination, query_security_test.rb for the security model, structure_test.rb / like_spec_test.rb / form_test.rb for the units, and full_integration_test.rb against the dummy app.