inline_forms
Inline Forms is almost a complete admin application. You can try it out easily.
Release status notice
All gem versions after 6.2.14 are currently broken.
We will post a notice when the gem is good again.
Usage
gem install inline_forms
If you want to just start a new app called MyApp:
inline_forms create MyApp
If you want to use mysql instead of sqlite as development database:
inline_forms create MyApp --database mysql
If you want to install the example application:
inline_forms create MyApp -d sqlite --example
Then point your browser to localhost:3000/apartments and log in with admin@example.com / admin999. The example also adds integration and model tests; run bundle exec rails test in MyApp, then start the server with bundle exec rails s when you want the UI.
The example app now ships three models:
-
Apartment— top-level resource at/apartments(root). -
Photo— nested under Apartment (has_many/belongs_to). -
Owner— top-level resource at/owners. An Ownerhas_manyApartments; an Apartmentbelongs_toa single (optional) Owner. The Owner detail panel at/owners/:idships two sub-tabs,NAW(name, birthdate, address, city, country) andApartments(name + the owned-apartments checklist).namedeliberately appears on both tabs.
On the Apartments tab, Owner#apartments is rendered as a :check_list of existing apartments rather than the default :associated “build new nested row” panel, so the user re-assigns the apartments.owner_id FK by ticking checkboxes (using Rails’ built-in apartment_ids= setter on has_many).
The tabs are wired with tabs_on_rails (set_tab / current_tab?) and InlineForms::TurboTabsBuilder, a small subclass of TabsOnRails::Tabs::TabsBuilder that threads HTML options through to the tab’s <a> (upstream 3.0 can only annotate the <li>). Each tab link carries data-turbo-frame targeting the surrounding row <turbo-frame>, so switching tabs is a single Turbo partial swap — no UJS, no data-remote. The active tab is emitted as an hrefless <a aria-current=“page” aria-selected=“true”> inside <li class=“tabs-title is-active”> so Foundation 6’s tabs CSS (+.tabs-title.is-active > a+ / [aria-selected=‘true’]) styles it without needing custom overrides.
The example app also seeds three apartments (+Apt 1+, Apt 2, Apt 3, each with three CC0 placeholder photos from the gem’s pics/ folder) and three owners — Maria Martinez (owns Apt 1 + Apt 2), Jean-Pierre Dupont (owns Apt 3), and Akira Tanaka (owns none) — so the has_many panel has at least one zero / one / many case to click through.
You can install the example application manually if you like:
inline_forms create MyApp
cd MyApp
rails g inline_forms Picture name:string caption:string image:image_field description:plain_text apartment:belongs_to _presentation:'#{name}'
rails generate uploader Image
rails g inline_forms Apartment name:string title:string description:rich_text pictures:has_many pictures:associated _enabled:yes _presentation:'#{name}'
rails g inline_forms Owner name:string birthdate:date address:string city:string country:string apartments:has_many apartments:associated _enabled:yes _presentation:'#{name}'
# Add the apartments→owner FK by hand:
rails g migration AddOwnerToApartments owner:references
# Then in app/models/apartment.rb, add (under has_paper_trail):
# belongs_to :owner, optional: true
# and prepend `[ :owner, "owner", :dropdown ],` to inline_forms_attribute_list.
bundle exec rake db:migrate
rails s
Then point your browser to localhost:3000/apartments and log in with admin@example.com / admin999. Owners live at localhost:3000/owners and demonstrate the per-resource Turbo tabs.
Per-resource Turbo tabs (InlineForms::TurboTabsBuilder)
Upstream tabs_on_rails 3.0 (+TabsOnRails::Tabs::TabsBuilder#tab_for(tab, name, url_options, item_options = {})+) only applies the 4th argument to the <li> wrapper; nothing is forwarded to the <a>. That used to be fine under Rails UJS (every link with data-remote=“true” was hijacked into an XHR regardless), but Turbo needs the data attribute on the <a> itself (typically data-turbo-frame=“…”).
The old acesuares/tabs_on_rails fork (update_remote_before_action) patched tab_for to thread html options into link_to. That fork was retired in 7.13.5; InlineForms::TurboTabsBuilder is its Turbo-shaped replacement. It accepts a new :link_options key on the per-tab call and forwards it to link_to:
<%= tabs_tag builder: InlineForms::TurboTabsBuilder,
active_class: "is-active",
open_tabs: { class: "tabs owner_tabs",
id: "owner_#{@object.id}_tabs",
"data-tabs": "" } do |tab| %>
<%= tab.naw "NAW",
owner_path(@object, tab: :naw, update: @update_span),
class: "tabs-title",
link_options: { data: { turbo_frame: @update_span } } %>
<%= tab.apartments "Apartments",
owner_path(@object, tab: :apartments, update: @update_span),
class: "tabs-title",
link_options: { data: { turbo_frame: @update_span } } %>
<% end %>
Active-tab highlighting is unchanged from upstream (still driven by set_tab :foo / current_tab?); the controller picks which attribute subset to render and just calls render “owners/show_with_tabs”. See app/controllers/owners_controller.rb and app/views/owners/ in a freshly generated --example app for the full pattern.
Where to put the tabs_tag block (four patterns)
tabs_on_rails and InlineForms::TurboTabsBuilder don’t care where you call tabs_tag — they just produce the <ul class=“tabs”> wherever you put the block. The example app’s split into show_with_tabs.html.erb + _owner_tabs.html.erb is a stylistic choice; below are the four common shapes, from most-inline to most-decoupled. Pick whichever fits your app:
-
*Inlined in the show view* — drop the
tabs_tagblock straight into app/views/<resource>/show_with_tabs.html.erb (or similar) and skip the partial entirely.<%# app/views/owners/show_with_tabs.html.erb %> <turbo-frame id="<%= @update_span %>"> <%= tabs_tag builder: InlineForms::TurboTabsBuilder, active_class: "is-active", open_tabs: { class: "tabs", id: "owner_#{@object.id}_tabs", "data-tabs": "" } do |tab| %> <%= tab.naw "NAW", owner_path(@object, tab: :naw, update: @update_span), class: "tabs-title", link_options: { data: { turbo_frame: @update_span } } %> <%# ... more tabs ... %> <% end %> <%= render partial: "inline_forms/show" %> </turbo-frame>Best when the strip is one-off and you won’t reuse it.
-
*Dedicated tab-strip partial* (what the
--exampleapp does). Keep the show view tiny and move the strip into app/views/<resource>/_<resource>_tabs.html.erb. Theinline_formsexample uses this so thetabs_tagblock can be iterated over aOWNER_TABSconstant:<%# app/views/owners/_owner_tabs.html.erb %> <%= tabs_tag builder: InlineForms::TurboTabsBuilder, active_class: "is-active", open_tabs: { class: "tabs", id: "owner_#{@object.id}_tabs", "data-tabs": "" } do |tab| %> <% (@inline_forms_owner_tabs || OwnersController::OWNER_TABS).each do |t| %> <%= tab.send(t, t("owner_tabs.#{t}", default: t.titleize), owner_path(@object, tab: t, update: @update_span), class: "tabs-title", link_options: { data: { turbo_frame: @update_span } }) %> <% end %> <% end %> <%# app/views/owners/show_with_tabs.html.erb %> <turbo-frame id="<%= @update_span %>"> <%= render partial: "owners/_owner_tabs" %> <%= render partial: "inline_forms/show" %> </turbo-frame>Best when the same tab strip needs to appear above several views (e.g.
show,edit, custom report) and the controller drives the list of tabs. -
*Helper-driven, reusable across resources* — extract the
tabs_tagcall into a view helper (e.g. InlineFormsTabsHelper#inline_forms_turbo_tabs_for) so multiple resources can share the same strip with one line:# app/helpers/inline_forms_tabs_helper.rb module InlineFormsTabsHelper def inline_forms_turbo_tabs_for(object, tabs, update:, i18n_scope: nil) tabs_tag builder: InlineForms::TurboTabsBuilder, active_class: "is-active", open_tabs: { class: "tabs", id: "#{object.class.name.underscore}_#{object.id}_tabs", "data-tabs": "" } do |tab| tabs.each do |t| label = t("#{i18n_scope}.#{t}", default: t.to_s.titleize) if i18n_scope label ||= t.to_s.titleize concat tab.send(t, label, polymorphic_path(object, tab: t, update: update), class: "tabs-title", link_options: { data: { turbo_frame: update } }) end end end end <%# in any show view %> <%= inline_forms_turbo_tabs_for(@object, OwnersController::OWNER_TABS, update: @update_span, i18n_scope: "owner_tabs") %>Best when you have several resources that all need a per-row tab strip and you don’t want to duplicate the
tabs_tagboilerplate. -
*One partial per tab content* — keep the strip partial (option 2), but make the show view render “owners/tabs/#{params}” and ship a separate
_naw.html.erb/_apartments.html.erbper tab. Each tab partial owns its own markup (custom forms, charts, lists of foreign objects, …) instead of going throughinline_forms/_show:<%# app/views/owners/show_with_tabs.html.erb %> <turbo-frame id="<%= @update_span %>"> <%= render partial: "owners/_owner_tabs" %> <%= render "owners/tabs/#{params[:tab].presence || 'naw'}" %> </turbo-frame> <%# app/views/owners/tabs/_naw.html.erb %> <%= render partial: "inline_forms/show" %> <%# stock inline_forms behaviour %> <%# app/views/owners/tabs/_apartments.html.erb %> <h3>Owned apartments (<%= @object.apartments.size %>)</h3> <ul> <% @object.apartments.order(:name).each do |apt| %> <li><%= link_to apt.name, apartment_path(apt) %> — <%= apt.title %></li> <% end %> </ul> <%# ... or a chart, an upload form, an external API widget, anything ... %>Best when tabs need wildly different markup that the inline_forms attribute-list shape can’t express (custom dashboards, mixed-resource pages, embedded reports).
-
*Grouped tab strips* — render *two or more*
tabs_tagblocks side-by-side (or stacked with a separator) when the resource has logically distinct groups of tabs that share the sameset_tabmachinery but should be visually separated. Common example: an “info” group (name, contact, notes) and a “process” group (intake, assessment, plan) on aClientdetail page:# app/controllers/clients_controller.rb class ClientsController < InlineFormsController set_tab :client INFO_TABS = %w[naw contact notes].freeze PROCESS_TABS = %w[intake assessment plan].freeze ALL_TABS = (INFO_TABS + PROCESS_TABS).freeze TAB_FIELDS = { "naw" => %i[name birthdate address city country], "contact" => %i[name email phone], "intake" => %i[name intake_date intake_notes], # ... one entry per tab; `name` repeated where it should appear }.freeze def show return super if params[:form_element] || params[:attribute] || params[:close] @object = Client.find(params[:id]) @update_span = params[:update].presence || "client_#{@object.id}" tab = ALL_TABS.include?(params[:tab].to_s) ? params[:tab].to_s : ALL_TABS.first set_tab tab.to_sym @inline_forms_attribute_list = TAB_FIELDS.fetch(tab).map { |a| @object.inline_forms_attribute_list.find { |attr, _, _| attr == a } } render "clients/show_with_tabs", layout: turbo_frame_request? ? "turbo_rails/frame" : "inline_forms" end end <%# app/views/clients/_client_tabs.html.erb -- two separate strips %> <%= tabs_tag builder: InlineForms::TurboTabsBuilder, active_class: "is-active", open_tabs: { class: "tabs info_tabs", id: "client_#{@object.id}_info_tabs", "data-tabs": "" } do |tab| %> <% ClientsController::INFO_TABS.each do |t| %> <%= tab.send(t, t("client_tabs.#{t}", default: t.titleize), client_path(@object, tab: t, update: @update_span), class: "tabs-title", link_options: { data: { turbo_frame: @update_span } }) %> <% end %> <% end %> <hr class="tab_group_separator"> <%= tabs_tag builder: InlineForms::TurboTabsBuilder, active_class: "is-active", open_tabs: { class: "tabs process_tabs", id: "client_#{@object.id}_process_tabs", "data-tabs": "" } do |tab| %> <% ClientsController::PROCESS_TABS.each do |t| %> <%= tab.send(t, t("client_tabs.#{t}", default: t.titleize), client_path(@object, tab: t, update: @update_span), class: "tabs-title", link_options: { data: { turbo_frame: @update_span } }) %> <% end %> <% end %>Each
tabs_tagcall is fully independent — differentopen_tabsclasses, different ids — but they shareset_tab/current_tab?, so the active highlight is always on the one tab whose name matchesparams[:tab], regardless of which strip it sits in. Best when tabs fall into clearly distinct categories on the same page (info vs. workflow, read-only vs. write, primary vs. admin) and you want CSS / spacing control between the groups.
The --example app uses option 2 because the only per-tab difference is which attribute subset to render, and inline_forms/_show already drives off @inline_forms_attribute_list — so a single filter in OwnersController#show is enough and a per-tab partial would be over-engineering. For real apps with heterogeneous tabs, option 4 is usually a better fit; option 5 layers on top of any of the others when you need visual grouping.
In every case the Turbo wiring is the same: link_options: { data: { turbo_frame: @update_span } } on the <a>, surrounding <turbo-frame id=“<%= @update_span %>”> in the show view, and a controller that picks the active tab via set_tab + params[:tab]. The InlineForms::TurboTabsBuilder choice is independent of which partial layout you adopt.
Generated application rails-i18n
New apps get rails-i18n from RubyGems (+ ‘~> 7.0’+), not from the svenfuchs/rails-i18n Git repository. Release line 7.0.x declares railties between 6 and 8, which matches the template’s Rails 6.1.3.1. The upstream repository’s default branch is aimed at newer Rails and would pull in railties 8+, which cannot be resolved together with Rails 6.1.
File uploads (CarrierWave)
The :image_field form element uses CarrierWave. Generated apps depend on carrierwave ‘~> 3.1’ from RubyGems, store uploads on the local filesystem under public/uploads/, and use the default uploader produced by rails generate uploader Image. CarrierWave 3.1 supports Rails 6.0 through 8.0 and is the upstream maintenance line.
To switch to S3, add carrierwave-aws (or use the bundled fog backend) and configure a CarrierWave.configure block in config/initializers/carrierwave.rb; nothing in inline_forms hard-codes local storage.
PaperTrail-driven restore keeps previous image bytes
PaperTrail snapshots the column scalar (a CarrierWave filename) on update; CarrierWave’s defaults overwrite the previous file on disk and reuse the same filename, so a vanilla version.reify; save! ends up restoring a filename whose bytes are gone. The generated ImageUploader ships three knobs that fix this:
-
CarrierWave.configure { |c| c.remove_previously_stored_files_after_update = false } in
config/initializers/carrierwave.rb— covers:multi_image_fielduploaders too. -
remove!overridden to a no-op, so hard-destroyed records keep their bytes and revert-after-destroy can still find them. -
filenameprefixed with a per-upload UUID, so successive uploads never collide on disk.
Trade-off: files accumulate on disk; periodic sweeping is out of scope of the gem. Source: stackoverflow.com/questions/9423279/papertrail-and-carrierwave (Answers 2, 4 and 5).
For long text fields, use :plain_text for a plain textarea backed by a DB text column, or :rich_text for ActionText/Trix content. :plain_text requires an actual column on the model table; if the column is missing, inline_forms now raises InlineForms::PlainTextColumnMissingError during controller boot/runtime checks.
Note: generated apps also depend on ActiveStorage transitively because the :rich_text form element uses ActionText (active_storage:install runs during inline_forms create). Image uploads still go through CarrierWave; ActiveStorage is only there to back ActionText embeds.
Build a vagrant virtualbox box for easier development
Go ahead and unzip lib/vagrant/vagrantbox-inline_forms.zip. Enter the created directory with
cd vagrantbox-inline_forms
then issue
vagrant up
after a while you should be able to use the created box like this:
vagrant ssh
Once inside the box, goto /vagrant and install_stuff:
cd /vagrant
./install_stuff
This should update your box, install rvm and ruby and inline_forms, and create an example app.
Disclaimer
It’s work in progress. Until I learn to use git branch, new releases break as easy as Elijah Price’s bones.
Copyright
Copyright © 2011-2015 Ace Suares. See LICENSE.txt for further details.