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 http://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 ships four demo models (plus the User / Role / Locale auth trio):

  • 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.
  • FormElementShowcase — one row per kept Tier 1 form element (numbers, dates, choices, scales, uploads incl. a multi_image_field gallery, rich text, HABTM checklist), seeded as "Full demo" / "Empty demo" so every show/edit branch renders and is regression-tested.

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 InlineForms::Tabs (+set_tab+ / current_tab? / tabs_tag — vendored into the engine since 8.1.23, replacing the unmaintained tabs_on_rails gem with the same API) and InlineForms::TurboTabsBuilder, a small subclass of InlineForms::Tabs::TabsBuilder that threads HTML options through to the tab's <a> (the classic builder 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 shipped with the installer — labeled pastel PNGs like "Apt 1 / Living Room"; override with your own folder via INLINE_FORMS_SEED_PICS) 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 http://localhost:3000/apartments and log in with admin@example.com / admin999. Owners live at http://localhost:3000/owners and demonstrate the per-resource Turbo tabs.

Per-resource Turbo tabs (+InlineForms::TurboTabsBuilder+)

The classic builder API (+tab_for(tab, name, url_options, item_options = {})+, inherited from the retired tabs_on_rails gem and preserved verbatim in the vendored InlineForms::Tabs::TabsBuilder) 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 (and the gem itself was vendored away in 8.1.23); 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@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)

InlineForms::Tabs 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[:tab]}" 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 %>
    

    <%= 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: https://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.

Native inputs (no jQuery UI)

Since 8.1.25/8.1.26 the date/time widgets are native HTML inputs — :date_select renders <input type="date">, :time_select <input type="time">, :month_year_picker <input type="month">, :slider_with_values <input type="range">, :dropdown_with_other an <input list> + <datalist> combobox, and the new :color_field (8.1.32) <input type="color">. jQuery UI, its themes and the jquery-ui-rails / rails-jquery-autocomplete / jquery-timepicker-rails gems are gone; jQuery itself remains only because Foundation 6's JS requires it.

Per-user themes, colors and locale

Generated apps let each user pick, on their own user panel:

  • a preset theme (+default+ / dark / sepia / high-contrast): an integer theme column mapped to a theme-<name> body class; palettes are CSS custom properties (+--if-color-*+) in the engine's _theme.scss (8.1.29);
  • custom colors (+primary_color+ / accent_color, edited with :color_field): rendered as one scoped inline <style> of --if-color-* overrides, so the main stylesheet stays fully cacheable (8.1.32);
  • a locale: the existing locale association now drives I18n.with_locale per request via around_action :switch_locale (8.1.31).

Host apps that are not generated by the installer opt in by making current_user respond to inline_forms_theme (String) and/or inline_forms_color_overrides (Hash of --if-color- suffix => "#rrggbb"). Everything is validated before interpolation. To re-theme an app globally instead, override the variables in your own CSS:

:root { --if-color-primary: #2563eb; }

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 (c) 2011-2015 Ace Suares. See LICENSE.txt for further details.