A routes.rb DSL that mounts ready-made resource screens.
Add one line to config/routes.rb and Recourse draws the routes and serves the
controller and views needed to browse and edit a resource. Nothing is written
into your app: every controller, template and partial it supplies is a default,
and defining your own takes precedence over it.
Status: early development.
index,new,create,edit,updateanddestroywork;showand the eject generator are not implemented yet.
- Ruby 3.2 or newer.
- Rails 8.1 or newer —
actionpack,activerecordandrailties. pagy43.6 or newer, which paginates every index.ransack4.4 or newer, which sorts, searches and filters every index.
The gem is called drive; the library it loads calls itself Recourse.
gem 'drive'Then run bundle install. There is nothing to mount and no initializer to
write: the engine adds the routing DSL, the model hooks and a file server for
its own CSS and JavaScript as the app boots.
# config/routes.rb
Rails.application.routes.draw do
recourses :contacts
recourses :markets, only: %i[index new create edit update]
recourses :states, only: :index
recourses :placeholders, only: []
endrecourses accepts everything resources accepts — only:, except:, a
block to nest in — and for each name it does three things:
- Records the name in
Recourse.declared, in the orderroutes.rblists it. The sidebar follows that order, not an alphabetical one. - Defines
ContactsControlleras a subclass ofRecoursesController, unless the constant already resolves. A Zeitwerk autoload counts, so a file inapp/controllersis enough to keep the gem from defining anything. - Draws the routes, by calling
resourceswith the arguments it was given.
Because the third step is plain resources, recourses :contacts also routes
show, which no gem-supplied action answers yet. Pass only: to draw the six
that work, or write show yourself.
A resource with no index route gets no sidebar entry, which is what
only: [] is for.
| Action | What it answers |
|---|---|
index |
one page of the model — 20 rows, ?page=2 for the next |
new |
a blank record's form |
create |
the index again, or the form with the errors on it |
edit |
the form for the record the id names |
update |
the index again, or the form with the errors on it |
destroy |
the index again, without the record |
index reads the model's recourse_includes and recourse_order, so a table
cell naming a referenced record costs no query of its own. The table hides
every encrypted column, and a model with no rows renders No contacts.
instead. A heading sorts the table by its own column where the model allows
it, and the form above the table narrows what it shows, by search or by
filter.
new and edit assign the record twice: to @recourse, and to the name Rails
would use, so @contact is what a view of yours can read.
destroy is offered from the edit page, as a button beside the breadcrumb, and
only where the action is both implemented and routed. It asks first, through
data-turbo-confirm, naming the record and counting one level of what goes with
it: 2 messages will be deleted with it. for a dependent: :destroy,
1 message will be kept, without a job. for a :nullify, and
Anything under those goes too. in place of the levels below — a state reaches
counties, then ZIPs, then locations, and counting that far would join 40,965 rows
to draw one page. Without Turbo loaded there is no confirmation at all, only the
delete.
create and update permit every editable column, then take one of two
branches. Saved, they set flash.notice to Contact was created. and redirect
to the index with 303 See Other; rejected, they set flash.now.alert and
re-render the form with 422 Unprocessable Entity. edit and update look
their record up with find, so an id that names nothing raises
ActiveRecord::RecordNotFound and Rails answers 404.
Two column lists decide what a screen shows, and they are deliberately not the same one:
- A table shows every column except the encrypted ones, so a column holding PII
never reaches an index page, and except the primary key.
created_atandupdated_atcome last, after whatever the record is actually about. - A form offers, and
createpermits,Recourse.editable_columns— every column exceptid,created_atandupdated_at. Encrypted columns are offered, as password fields.
A cell renders by what the column holds: a belongs_to's foreign key as the
label of the record it points at, a time as Aug 4 at 03:47pm EDT, an array as
its values joined by commas, a phone through number_to_phone.
Every Active Record model answers four class methods: the engine extends
ActiveRecord::Base with Recourse::Recoursive on load, so the defaults are
there without a model mentioning them.
| Method | Default | What it decides |
|---|---|---|
recourse_label |
:name |
the column that stands for a record — what a combobox lists, and what a table cell shows for a foreign key pointing here |
recourse_typed_label? |
true when that column has a length validator | whether a foreign key to this model is typed into a text field or picked from a list |
recourse_includes |
every belongs_to the table names |
what the index eager-loads, in any shape includes accepts |
recourse_order |
:id |
how the index sorts, in any shape order accepts |
Overriding one means overriding a class method, which is what the Recoursive
concern next to the model is for:
# app/models/zip/recoursive.rb
class ZIP
module Recoursive
extend ActiveSupport::Concern
class_methods do
def recourse_label = :code
def recourse_order = :code
end
end
end# app/models/zip.rb
class ZIP < ApplicationRecord
include Recoursive
endrecourse_label has to name a real column rather than a method, since it is
selected alongside the id. Pick an encrypted column and its plaintext is what
the label reads — on every page that references the model, not just the form.
The engine extends every model with Recourse::Searchable too, loaded right
after Ransack so its extend lands ahead of Ransack's own defaults in the
singleton ancestor chain — ours win. These are the hooks behind a sortable
heading, a search box and a filter:
| Method | Default | What it decides |
|---|---|---|
ransackable_attributes |
every column but the encrypted ones | which columns a search or a filter may read |
ransackable_associations |
the foreign keys the search box reaches through | which other tables a predicate may join |
ransortable_attributes |
the timestamps, plus every column an index covers, less every foreign key | which headings can be clicked to sort |
search_field |
every indexed string column, plus the label behind every foreign key whose model is too long to list, ORed and matched on containment | what the search box searches — nil where there is nothing to look through, and no search box either |
search_prompt |
Filter by, then those same columns joined by or |
what the search box says while it is empty |
filter_fields |
one _in entry per belongs_to, less the ones the search box reaches through |
which foreign keys get a filter, and what draws it |
recourse_searchable? |
true when there is a search field or any filter | whether the form above the table renders at all |
recourse_listable? |
true when the table holds no more than MENU_LIMIT rows |
whether a foreign key pointing here gets a menu or joins the search |
A State answers 'code_or_fips_or_name_cont' for the first and 'Filter by Code or Fips or Name' for the second, since code, fips and name are its
only columns that are both indexed and a searchable type — a string, text,
citext or enum, an enum's value being a word even though its own Postgres type
is not. An index is the only signal a schema carries about which column
identifies a row rather than describes it, so that is what both hooks read. The
prompt spells each column the way human_attribute_name does, capital and all:
downcasing it would spell a registered acronym back out as a word, zip where
every heading reads ZIP.
A foreign key is the other half of what a search looks through, and what decides
is how long the other table is. A menu is a control while every row fits in one;
past that it is a page of HTML nobody reads. So a belongs_to whose model is not
recourse_listable? — more than MENU_LIMIT, which is 100 — gets no filter, and
its label joins the search instead. /locations answers 'zip_code_cont' for
40,965 ZIPs; /zips answers 'code_or_county_name_cont' for 3,144 counties and
keeps the menu for its markets. Each names that one association in
ransackable_associations, so exactly the join being searched is allowed.
The label has to be a word for that to mean anything — a string, text, citext or
enum — since a cont against an id or a date matches nothing. A model too long
to list whose label is neither leaves the foreign key with no filter and no
search, and scope: on a filter_fields entry is what draws a menu for it
anyway.
No foreign key's column is sortable, these included: the cell shows a label from another table, and the id under it is not the order that label reads in.
recourse_listable? counts once per class and counts no further than it has to —
LIMIT 101 — so the question costs 0.03ms whether the table holds ten rows or
ten million. A table that crosses the line is noticed at the next boot.
Overriding one is the same shape as Recoursive: a same-named concern beside
the model, defining inside class_methods do. The dummy app's Market widens
its search past what an index suggests, and renames the filter it is narrowed
with:
# app/models/market/searchable.rb
class Market
module Searchable
extend ActiveSupport::Concern
class_methods do
def search_field = 'name_or_email_cont'
def search_prompt = 'Filter by name or email'
def filter_fields = { 'state_id_in' => { label: 'Home state' } }
end
end
end# app/models/market.rb
class Market < ApplicationRecord
include Searchable
endTwo questions split the two kinds of foreign key, and either one sends it to a
text field. recourse_typed_label? asks whether the label is bounded — a length
validator — and so can be typed: a ZIP code can. recourse_listable? asks
whether the table is short enough that a menu of every row is a control rather
than a page: 3,144 counties are not. A county name answers no to the first and
still gets a text field through the second, which is the same call the filter
beside it makes.
Either way the field asks for the label under the foreign key's own name, and
the controller looks the record up on the way in — ZIP.find_by code: '90210' —
so no model needs a virtual attribute and no strong parameter needs a special
case.
| Column | Field |
|---|---|
| a foreign key whose label is typed, or whose table is too long to list | text field, resolved to an id on submit |
| any other foreign key | a searchable combobox of every record, by label |
| an encrypted attribute | password field |
email, color |
email field, color field |
a date, time or datetime attribute |
date, time or datetime-local field |
| anything else | text field |
The type comes from the model's own type_for_attribute, so an attribute :opens_on, :date override counts.
What the browser then enforces is read from the validators, never from the
schema: maxlength and minlength from a length validator, pattern from a
format validator's regexp with its anchors removed, required from a presence
validator on the column or on the association, a numeric inputmode where the
pattern admits only digits. A field with a pattern also gets a title naming a
value that would match, and an optional field gets Optional as its
placeholder. A constraint your database has and your model does not is a
constraint no field can show.
A heading sorts its own column when the model's ransortable_attributes
allows it. The row partial draws every heading through sort_header(name)
rather than a bare title:
<%= column header: sort_header('name') do %>
<%= resource_cell record, 'name' %>
<% end %>It returns a sort link only on the header pass, with its own caret — up for
ascending, down for descending, none where nobody sorted by that column — and
the plain title on every other pass, so a <td>'s data-cell stays readable
text. The link restarts the table at its first page, since a sort keeps
whatever the request was already searching or filtering by and only replaces
the order.
The index builds a GET form — a search box for the model's search_field, one
filter per filter_fields entry, nothing at all where recourse_searchable? is
false — and puts it in content_for :search rather than drawing it anywhere.
Your layout has to yield :search, the same way it yields :actions, or
the form is built and never shown. The layout the gem ships yields it in the
navbar, to the right of the breadcrumb and the buttons. A filter reuses the combobox from
"Comboboxes for foreign keys" with multiple: true, so a request can narrow a
table to more than one of what a foreign key points at — ?q[state_id_in]=1,2
for two states at once. A foreign key whose model is too long to list — the ZIP
on /locations, the county on /zips — is offered no filter at all, since the
menu would be the whole table. Its label goes into the search box instead:
?q[zip_code_cont]=005 narrows one page by joining zips, and
?q[code_or_county_name_cont]=Autauga narrows the other by joining counties.
Naming that predicate in filter_fields with a scope: still offers a menu,
over whichever relation the scope names.
Typing in the search box, or picking from a filter's menu, submits the form
itself — a Stimulus controller resubmits 300ms after the last keystroke, and
immediately on every option ticked or unticked. Only the table and its
pagination are replaced by the answer: they sit in a <turbo-frame id='results'>
that the form targets, so an open menu stays open, the caret stays where it was
typing, and the address bar still advances to the query that produced the table.
Without Turbo the same form is an ordinary GET that reloads the page, and the
caret is put back into the search box by hand.
What a search matched is marked in the cell that matched it, so twenty rows that
all matched still say why each one did. Only the columns the search looked
through are marked — including the label behind a foreign key it reached
through, so /locations marks the ZIP code. A row partial of your own gets the
same by calling search_highlight.
Links need to know about that frame. A heading's sort and a pagination link
navigate it, which is the point of it — and that sort is read back off the URL
into the form's hidden q[s], so the next search keeps the order the last click
asked for. Every other link in a table has to leave the frame, since the page
it goes to has no frame of that name and Turbo would answer Content missing.
The edit pencil does; a row partial of your own should use turbo_link_to, which
is link_to with data-turbo-frame='_top' already on it:
<%= column header: 'Name' do %>
<%= turbo_link_to contact.name, contact_messages_path(contact) %>
<% end %>A model overrides any of this in its own Searchable concern; see "What a
model can say".
What this costs:
- A search reads the whole table: a
contpredicate isILIKE '%…%', which cannot use a btree index. - A filter's combobox selects every row of the model it offers, which is what
the typed-label rule and a
scope:are both for. - A search that reaches through a foreign key joins that table, and the containment is never indexed there either. Which side the planner drives from decides the cost: with few rows on this side it index-scans the other and tests each match, and with many it scans the other table once and hashes.
- A sort Ransack applies has no tiebreaker, so rows tied on the sorted column can shuffle between pages.
- Pagy's own count runs on the filtered relation, so a narrower filter is a cheaper count too, not just a shorter table.
Anything your app defines wins, because your app's view paths come first and
define_missing steps aside for a controller that already exists.
| Define this | To replace |
|---|---|
app/controllers/contacts_controller.rb |
the whole controller |
app/views/contacts/index.html.erb |
the index template — new and edit the same way |
app/views/contacts/_row.html.erb |
the cells of one row |
app/views/contacts/_fields.html.erb |
the fields of the form |
app/views/recourses/_sidebar.html.erb |
a shared partial, for every resource at once |
Templates are looked up under contacts/, then recourses/, then
application/, since those are the controller's prefixes. That is what lets a
partial be replaced for one resource or for all of them — and it is why a
controller of your own should subclass RecoursesController if it wants to keep
the gem's views. A controller inheriting straight from ApplicationController
has no recourses/ prefix, so it finds none of them.
A template of yours can still call the gem's partials:
<% content_for :title, 'States' %>
<%= render 'table', recourses: @resources, pagy: @pagy %>A row partial is rendered once for the header row and once per record. It
declares the record as a strict local, under the singular name of the resource,
and builds its cells with column:
<%# locals: (contact:) -%>
<%= column header: 'Name' do %>
<%= contact.name %>
<% end %>
<%= column header: 'Created at', class: 'text-nowrap' do %>
<%= resource_cell contact, 'created_at' %>
<% end %>column draws a <th> on the header pass and a <td> on every other, and its
block runs only for a real record — which is why contact arriving as nil
for the header row is not a problem.
A fields partial is the same shape, and builds its fields with field. Pass
label: to override the heading, and type: to override the field the column
would have chosen:
<%# locals: (contact:) -%>
<%= field :phone, type: :phone %>
<%= field :email, type: :email %>
<%= field :name, label: 'First name' %>
<%= field :surname, label: 'Last name' %>The form builder is not a local — it reaches field through @recourse_form,
so field :phone is all the call site has to say.
Every string the gem renders comes from config/locales/recourse.en.yml under
the recourse key, so rewording one takes a locale file of your own rather than
a reopened helper:
# config/locales/en.yml
en:
recourse:
add: Create a new %{model}
none: "Nothing here yet."
select: Select an airplane…The model's own name is %{model} and its plural %{models}, and both come from
model_name.human — so translating activerecord.models.contact renames it
everywhere at once, in the button and the flash alike.
The gem's own copy carries no a or an anywhere it interpolates a model,
because which one is right depends on how the word sounds rather than how it is
spelled — an hour, a user, a ZIP, an SMS — and no rule gets that right in
every language a key might be translated into. Select… says as much as Select a State… under a label that already reads State. If your models all take the
same article, the keys above are where you say so.
RecoursesController does helper Recourse::Helpers, so these are available in
any template or partial it renders. A controller of your own that does not
subclass it can include the module the same way.
Naming the resource on the page:
resources_name—'Contacts'resource_name—'contact'resource_key—:contact, the local a row or fields partial receivesresource_record— the record the action built, read from the assignsresource_record_label— what that record is called, by its model's label
Building a table:
column(header:, **, &)— one cell, a heading on the header passresource_columns— the columns a table showsresource_column_title(column)— a heading, translatable like any attributeresource_cell(record, column)— one value, formatted by what it holdssearch_highlight(value, column)— that value with the current search marked in it, where the search looked through that column at allsort_header(column, title = nil)— a heading that sorts by that column where the model allows it, the plain title otherwise. It calls Ransack'ssort_linkrather than replacing it, so that helper stays yours to use
Searching and filtering:
search_form— the search and filter form, or nothing where the model offers neither a search field nor a filter. The index hands it tocontent_for :search, so a layout is what decides where it goesfilter_field(predicate, label: nil, scope: nil)— one filter, a multiple combobox of the records a foreign key points at
Building a form:
field(name, label: nil, type: nil)— one labelled field in the grideditable_columns— the columns a form offersresource_field(form, column, type: nil)— the field alone, unlabelledcombobox(form, column, association)— the menu a foreign key offersfield_html(column, type = nil, model = resource_model)— the browser-side constraints a column's validators add up topattern_example(pattern)— a value the pattern would accept
Foreign keys:
belongs_to_association(column)— the association a column is the key ofreference_field,reference_cell,reference_title— the field, the value and the heading for one
Chrome:
resource_breadcrumbs— the trail to this page, as[title, path]pairssidebar_resources— every declared resource with an index, in routes order, each with the position of the letter that reaches it from the keyboardcurrent_resource?(name)— whether a sidebar entry is this pageresource_label(title, key = nil)— an icon and a title, for a link to a resource, with the letter atkeymarked as its keyboard shortcutnew_resource_path,edit_resource_link(record),destroy_resource_button(record)— nil and nothing when the action is not defined or not routed, so a link never points at a404destroy_warning(record)— the text that button asks for confirmation withturbo_link_to(name, path, **options)—link_tofor a link inside a table, carrying thedata-turbo-frame='_top'that takes it out of the results frameflash_theme(key)— the Bootstrap theme one flash entry reads in
The gem vendors what its pages cannot render without and serves it from
/recourse/ through Rack::Static, so a host needs no asset pipeline:
bootstrap.min.css, bootstrap-icons.min.css with its fonts,
bootstrap.bundle.min.js, stimulus.js, and the gem's own
phone_controller.js and search_controller.js.
It also ships app/views/layouts/application.html.erb, which is what renders
when your app has no layout of its own. When it has one — and most do — the
pages render inside yours, so copy those two stylesheets and the Stimulus
module into it to see them styled, and yield what the screens contribute:
yield :title, yield :actions and yield :search. The last of those is where
the search and filter form goes; the gem's own layout yields it at the right of
the navbar.
The index table renders inside a cache_if params[:q].blank?, recourses
block, so a sorted or filtered table is drawn live instead of cached — two
requests can share a relation and still want different headings. A
combobox's list of options renders inside cache [recourses, multiple, selected], since the same relation is different markup as a single form
combobox and as a multiple filter, and the same menu is different markup
again with a different selection. Configuring a cache store is what turns
what is cached from correct into cheap.
Recourse::VERSIONRecourse.declared— the resources drawn, inroutes.rborderRecourse.declare(name)— records one, ignoring a repeated drawRecourse.editable_columns(model)— what a form offers andcreatepermitsRecourse::Search— the Ransack search behind an index;queryis theRansack::Searchthe views read as@q,scopeis the relationindexpaginatesRecourse::NAVIGATION_ICONS,Recourse::FALLBACK_ICON— the Bootstrap Icons name a sidebar title is drawn with, and the one an unlisted title falls back toRecourse::Error— the class every failure the gem reports will be, so a host can rescue one typeRecourse::Routes,Recourse::Controllers,Recourse::Engine— the wiring behindrecourses
After checking out the repo, run bin/setup to install dependencies. Then run
rake test to run the tests, or rake to run the tests and RuboCop. You can
also run bin/console for an interactive prompt.
The test suite boots a dummy Rails app against PostgreSQL, so it needs a server running; the database itself is created on the first run.
To install this gem onto your local machine, run bundle exec rake install.
Bug reports and pull requests are welcome on GitHub at https://github.com/claudiob/drive.
The gem is available as open source under the terms of the MIT License.