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 Owner has_many Apartments; an Apartment belongs_to a single (optional) Owner. The Owner detail panel at /owners/:id ships two sub-tabs, NAW (name, birthdate, address, city, country) and Apartments (name + the owned-apartments checklist). name deliberately 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:

  1. *Inlined in the show view* — drop the tabs_tag block 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.

  2. *Dedicated tab-strip partial* (what the --example app does). Keep the show view tiny and move the strip into app/views/<resource>/_<resource>_tabs.html.erb. The inline_forms example uses this so the tabs_tag block can be iterated over a OWNER_TABS constant:

    <%# 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.

  3. *Helper-driven, reusable across resources* — extract the tabs_tag call 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_tag boilerplate.

  4. *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.erb per tab. Each tab partial owns its own markup (custom forms, charts, lists of foreign objects, …) instead of going through inline_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) %> &mdash; <%= 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).

  5. *Grouped tab strips* — render *two or more* tabs_tag blocks side-by-side (or stacked with a separator) when the resource has logically distinct groups of tabs that share the same set_tab machinery but should be visually separated. Common example: an “info” group (name, contact, notes) and a “process” group (intake, assessment, plan) on a Client detail 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_tag call is fully independent — different open_tabs classes, different ids — but they share set_tab / current_tab?, so the active highlight is always on the one tab whose name matches params[: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_field uploaders too.

  • remove! overridden to a no-op, so hard-destroyed records keep their bytes and revert-after-destroy can still find them.

  • filename prefixed 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 © 2011-2015 Ace Suares. See LICENSE.txt for further details.