There's a lot of open issues
Installs the `inline_forms` CLI and scaffolds opinionated Rails apps with Devise, CanCan, PaperTrail, and optional example data.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 1.0, < 2.0
 Project Readme

inline_forms

Inline Forms is almost a complete admin application. You can try it out easily.

Requirements

  • Ruby **>= 4.0** (generated apps pin 4.0.4 via .ruby-version)

  • Rails 8.1.x (+rails ~> 8.1+, +config.load_defaults 8.1+)

  • validation_hints **~> 8** (companion gem; same version line as inline_forms / inline_forms_installer)

Ruby version managers (RVM is optional)

inline_forms does not require RVM. Generated apps get a .ruby-version whose format matches the version manager you are using: a bare 4.0.4 for rbenv / chruby / asdf / mise, or ruby-4.0.4 for RVM (whose .ruby-version reader needs the ruby- prefix). So any version manager — or none — works. Bundler isolates each app’s gems through its own Gemfile.lock.

RVM integration is purely opt-in. If the rvm gem is installed and your shell is inside an RVM environment, +inline_forms create+ additionally writes a .ruby-gemset and installs into a per-app gemset. To enable that, +gem install rvm+ before creating the app. To skip RVM even when it is present, pass --no-rvm:

inline_forms create MyApp -d sqlite --example --no-rvm

Usage

The +inline_forms create+ CLI ships in the inline_forms_installer gem (not in inline_forms itself). Install the installer to get the inline_forms executable:

gem install inline_forms_installer

Generated apps pin inline_forms and validation_hints at +~> 8+ (Bundler picks the latest 8.x). The three gems (inline_forms, inline_forms_installer, validation_hints) are released together with the same version number; use a current inline_forms_installer so the installer template matches current Rails pins. To add the engine to an existing Rails app without the CLI:

gem install inline_forms inline_forms_installer

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

To use a different Devise model (e.g. Member on a members table) while keeping Warden scope :user so current_user works in inline_forms:

inline_forms create MyApp -d sqlite --example --user-model Member

The installer emits +devise_for :users, class_name: “Member”, path: “members”+ (sign-in at /auth/members/sign_in), +resources :members+, and the usual authenticate_user! / destroy_user_session_path helpers.

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, :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 +id+s — 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 (+ ‘~> 8.1’+), not from the svenfuchs/rails-i18n Git repository. The installer pins +rails ~> 8.1+ with +config.load_defaults 8.1+; the published rails-i18n 8.x line matches that stack.

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.