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 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.FormElementShowcase— one row per kept Tier 1 form element (numbers, dates, choices, scales, uploads incl. amulti_image_fieldgallery, 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:
-
Inlined in the show view — drop the
tabs_tagblock straight intoapp/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 intoapp/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[:tab]}"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 %>
<%= 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 +id+s — 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 (+ '~> 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 }inconfig/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: 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 integerthemecolumn mapped to atheme-<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
localeassociation now drivesI18n.with_localeper request viaaround_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
Copyright (c) 2011-2015 Ace Suares. See LICENSE.txt for further details.