Tramway
Unite Ruby on Rails brilliance. Streamline development with Tramway.
Compatibility
Tramway is actively verified against the following Ruby and Rails versions.
| Ruby \ Rails | 7.1* | 7.2 | 8.0 | 8.1 |
|---|---|---|---|---|
| 3.2 | ✅ | ✅ | ✅ | ✅ |
| 3.3 | ✅ | ✅ | ✅ | ✅ |
| 3.4 | ✅ | ✅ | ✅ | ✅ |
* Rails 7.1 receives only residual support because it no longer receives updates from the Rails core team. See the announcement for details.
Installation
Add this line to your application's Gemfile:
gem "tramway"Then install Tramway and its dependencies:
bundle install
bin/rails g tramway:installThe install generator adds the required gems (haml-rails, kaminari, view_component, and dry-initializer) to your
application's Gemfile—if they are not present—and appends the Tailwind safelist configuration Tramway ships with.
Getting Started
Step 1
config/initializers/tramway.rb
Tramway.configure do |config|
config.entities = [
{
name: :user,
pages: [{ action: :index }],
}
]
endStep 2
config/routes.rb
Rails.application.routes.draw do
mount Tramway::Engine, at: '/'
endStep 3
class UserDecorator < Tramway::BaseDecorator
delegate_attributes :email, :created_at
def self.index_attributes
[:id, :email, :created_at]
end
endStep 4
If you ran bin/rails g tramway:install, the Tailwind safelist was already appended to config/tailwind.config.js.
Otherwise, copy this file to
config/tailwind.config.js.
Step 5
Run tailwincss-rails compiler
bin/rails tailwindcss:buildStep 6
Run your server
bundle exec rails sStep 7
Open http://localhost:3000/users
Usage
Tramway Entities
Tramway is an entity-based framework. Entity is the class on whose objects actions be applied: index, show, create, update, and destroy. Tramway will support numerous classes as entities. For now, Entity could be only ActiveRecord::Base class.
Define entity for Tramway
config/initializers/tramway.rb
Tramway.configure do |config|
config.entities = [ :user, :podcast, :episode ] # entities based on models User, Podcast and Episode are defined
endBy default, links to the Tramway Entities index page are rendered in Tramway Navbar.
Define entities with options
Tramway Entity supports several options that are used in different features.
route
config/initializers/tramway.rb
Tramway.configure do |config|
config.entities = [
{
name: :user,
namespace: :admin
}, # `/admin/users` link in the Tramway Navbar
{
name: :episodes,
route: {
route_method: :episodes
}
}, # `/podcasts/episodes` link in the Tramway Navbar
]
endscope
By default, Tramway lists all records for an entity on the index page. You can narrow the records displayed by providing a
scope. When set, Tramway will call the named scope on the entity before rendering the index view.
config/initializers/tramway.rb
Tramway.configure do |config|
config.entities = [
{
name: :campaign,
namespace: :admin,
pages: [
{
action: :index,
scope: :active
}
]
}
]
endIn this example, the Campaign entity will display only records returned by the active scope on its index page, while all
other pages continue to show every record unless another scope is specified.
show page
To render a show page for an entity, declare a :show action inside the pages array in
config/initializers/tramway.rb. Tramway will generate the route and render a table using the attributes returned by the
decorator's 'show_attributes` method.
Tramway.configure do |config|
config.entities = [
{
name: :campaign,
pages: [
{ action: :index },
{ action: :show }
]
}
]
endclass CampaignDecorator < Tramway::BaseDecorator
def show_attributes
%i[name status starts_at]
end
endWith this configuration in place, visiting the show page displays a two-column table where the left column contains the localized attribute names and the right column renders their values.
route_helper
To get routes Tramway generated just Tramway::Engine.
Tramway::Engine.routes.url_helpers.users_path => '/admin/users'
Tramway::Engine.routes.url_helpers.podcasts_episodes_path => '/podcasts/episodes'Tramway Decorators
Tramway provides convenient decorators for your objects. NOTE: This is not the decorator pattern in its usual representation.
app/controllers/users_controller.rb
def index
# this line of code decorates the users collection with the default UserDecorator
@users = tramway_decorate User.all
endapp/decorators/user_decorator.rb
class UserDecorator < Tramway::BaseDecorator
# delegates attributes to decorated object
delegate_attributes :email, :first_name, :last_name
association :posts
# you can provide your own methods with access to decorated object attributes with the method `object`
def created_at
I18n.l object.created_at
end
# you can provide representations with ViewComponent to avoid implementing views with Rails Helpers
def posts_table
component 'table', object.posts
end
endAdd header content to index pages
You can inject custom content above an entity's index table by defining an
index_header_content lambda on its decorator. The lambda receives the
collection of decorated records and can render any component you need.
config/initializers/tramway.rb
Tramway.configure do |config|
config.entities = [
{
name: :project,
pages: [
{ action: :index }
]
}
]
endapp/decorators/project_decorator.rb
class ProjectDecorator < Tramway::BaseDecorator
class << self
def index_header_content
lambda do |_collection|
component "projects/index_header"
end
end
end
endapp/components/projects/index_header_component.html.haml
.mb-2
= tramway_button path: Rails.application.routes.url_helpers.new_project_path,
text: 'Create',
type: :hopeapp/components/projects/index_header_component.rb
class Projects::IndexHeaderComponent < Tramway::BaseComponent
endWith this configuration in place, the index page will render the Create
button component above the table of projects.
Add header content to show pages
To inject custom content above a record's details, define an
object-level show_header_content method on its decorator. The method
can return any rendered content and has full access to the decorated
object's helpers and attributes.
Use view_context in show and index pages
Some helpers—such as button_to with non-GET methods—need access to the
live request context to include CSRF tokens. Pass the Rails view_context
into your decorated objects so their render calls execute inside the
current request.
def show
@project = tramway_decorate(Project.find(params[:id])).with(view_context:)
endFor index pages, decorate each record with the current context before rendering headers or per-row components:
def index
@projects = tramway_decorate(Project.all).map { |project| project.with(view_context:) }
endPassing the context this way ensures show_header_content and
index_header_content blocks can safely call helpers that require the
session-bound authenticity token.
Decorate a single object
You can use the same method to decorate a single object either
def show
@user = tramway_decorate User.find params[:id]
endAll objects returned from tramway_decorate respond to
with(view_context:), so you can attach the current Rails view_context
when you need decorator-rendered content to use helpers that rely on the
active request (such as CSRF tokens).
Decorate a collection of objects
def index
@users = tramway_decorate User.all
enddef index
@posts = tramway_decorate user.posts
endDecorate with a specific decorator
You can implement a specific decorator and ask Tramway to decorate with it
def show
@user = tramway_decorate User.find(params[:id]), decorator: Users::ShowDecorator
endDecorate associations
Decorate single association
class UserDecorator < Tramway::BaseDecorator
association :posts
end
user = tramway_decorate User.first
user.posts # => decorated collection of posts with PostDecoratorDecorate multiple associations
class UserDecorator < Tramway::BaseDecorator
associations :posts, :users
endDecorate nil
Tramway Decorator does not decorate nil objects
user = nil
UserDecorator.decorate user # => nilUpdate and Destroy
Read behave_as_ar section
Tramway Form
Tramway provides convenient form objects for Rails applications. List properties you want to change and the rules in Form classes. No controllers overloading.
*app/forms/user_form.rb
class UserForm < Tramway::BaseForm
properties :email, :password, :first_name, :last_name, :phone
normalizes :email, with: ->(value) { value.strip.downcase }
endControllers without Tramway Form
app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
@user = User.new
if @user.save user_params
render :show
else
render :new
end
end
def update
@user = User.find params[:id]
if @user.save user_params
render :show
else
render :edit
end
end
private
def user_params
params[:user].permit(:email, :password, :first_name, :last_name, :phone)
end
endControllers with Tramway Form
app/controllers/users_controller.rb
class UsersController < ApplicationController
def create
@user = tramway_form User.new
if @user.submit params[:user]
render :show
else
render :new
end
end
def update
@user = tramway_form User.find params[:id]
if @user.submit params[:user]
render :show
else
render :edit
end
end
endWe also provide submit! as save! method that returns an exception in case of failed saving.
Implement Form objects for any case
app/forms/user_updating_email_form.rb
class UserUpdatingEmailForm < Tramway::BaseForm
properties :email
endapp/controllers/updating_emails_controller.rb
def update
@user = UserUpdatingEmailForm.new User.find params[:id]
if @user.submit params[:user]
# success
else
# failure
end
endCreate form namespaces
app/forms/admin/user_form.rb
class Admin::UserForm < Tramway::BaseForm
properties :email, :password, :first_name, :last_name, :etc
endapp/controllers/admin/users_controller.rb
class Admin::UsersController < Admin::ApplicationController
def create
@user = tramway_form User.new, namespace: :admin
if @user.submit params[:user]
render :show
else
render :new
end
end
def update
@user = tramway_form User.find(params[:id]), namespace: :admin
if @user.submit params[:user]
render :show
else
render :edit
end
end
endNormalizes
Tramway Form supports normalizes method. It's almost the same as in Rails
class UserForm < Tramway::BaseForme
properties :email, :first_name, :last_name
normalizes :email, with: ->(value) { value.strip.downcase }
normalizes :first_name, :last_name, with: ->(value) { value.strip }
endnormalizes method arguments:
-
*properties- collection of properties that will be normalized -
with:- a proc with a normalization -
apply_on_nil- by default isfalse. WhentrueTramway Form applies normalization onnilvalues
Form inheritance
Tramway Form supports inheritance of properties and normalizations
Example
class UserForm < TramwayForm
properties :email, :password
normalizes :email, with: ->(value) { value.strip.downcase }
end
class AdminForm < UserForm
properties :permissions
end
AdminForm.properties # returns [:email, :password, :permissions]
AdminForm.normalizations # contains the normalization of :email Make flexible and extendable forms
Tramway Form properties are not mapped to a model. You're able to make extended forms.
app/forms/user_form.rb
class UserForm < Tramway::BaseForm
properties :email, :full_name
# EXTENDED FIELD: full name
def full_name=(value)
object.first_name = value.split(' ').first
object.last_name = value.split(' ').last
end
endAssign values
Tramway Form provides assign method that allows to assign values without saving
class UsersController < ApplicationController
def update
@user = tramway_form User.new
@user.assign params[:user] # assigns values to the form object
@user.reload # restores previous values
end
endUpdate and Destroy
Read behave_as_ar section
Tramway Navbar
Running tramway_navbar without arguments
When you call tramway_navbar without passing any arguments, it renders a navbar that lists buttons linking to all entities configured with a page index in config/initializers/tramway.rb.
Tramway provides DSL for rendering Tailwind Navgiation bar.
tramway_navbar title: 'Purple Magic', background: { color: :red, intensity: 500 } do |nav|
nav.left do
nav.item 'Users', '/users'
nav.item 'Podcasts', '/podcasts'
end
nav.right do
nav.item 'Sign out', '/users/sessions', method: :delete, confirm: 'Wanna quit?'
end
endERB example
<%= tramway_navbar title: 'Purple Magic', background: { color: :red, intensity: 500 } do |nav| %>
<% nav.left do %>
<%= nav.item 'Users', '/users' %>
<%= nav.item 'Podcasts', '/podcasts' %>
<% end %>
<% nav.right do %>
<%= nav.item 'Sign out', '/users/sessions', method: :delete, confirm: 'Wanna quit?' %>
<% end %>
<% end %>will render this
tramway_navbar
This helper provides several options. Here is YAML view of tramway_navbar options structure
title: String that will be added to the left side of the navbar
title_link: Link on Tramway Navbar title. Default: '/'
background:
color: Css-color. Supports all named CSS colors and HEX colors
intensity: Color intensity. Range: **100..950**. Used by Tailwind. Not supported in case of using HEX color in the background.color
with_entities: Show Tramway Entities index page links to navbar. Default: trueNOTE: tramway_navbar method called without arguments and block of code will render only Tramway Entities links on the left.
In case you want to hide entity links you can pass with_entities: false.
<% if current_user.present? %>
<%= tramway_navbar title: 'WaiWai' do |nav| %>
<% nav.left do %>
<%= nav.item 'Board', admin_board_path %>
<% end %>
<% end %>
<% else %>
<%= tramway_navbar title: 'WaiWai', with_entities: false %>
<% end %>nav.left and nav.right
Tramway navbar provides left and right methods that puts items to left and right part of navbar.
nav.item
Item in navigation is rendered li a inside navbar ul tag on the left or right sides. nav.item uses the same approach as link_to method with syntax sugar.
tramway_navbar title: 'Purple Magic' do |nav|
nav.left do
nav.item 'Users', '/users'
# NOTE you can achieve the same with
nav.item '/users' do
'Users'
end
# NOTE nav.item supports turbo-method and turbo-confirm attributes
nav.item 'Delete user', '/users/destroy', method: :delete, confirm: 'Are you sure?'
# will render this
# <li>
# <a data-turbo-method="delete" data-turbo-confirm="Are you sure?" href="/users/sign_out" class="text-white hover:bg-red-300 px-4 py-2 rounded">
# Sign out
# </a>
# </li>
end
endTramway Table Component
Tramway provides a responsive, tailwind-styled table with light and dark themes. Use the tramway_table, tramway_row, and
tramway_cell helpers to build tables with readable ERB templates while still leveraging the underlying ViewComponent
implementations.
<%= tramway_table do %>
<%= tramway_header headers: ['Column 1', 'Column 2'] %>
<%= tramway_row do %>
<%= tramway_cell do %>
Something
<% end %>
<%= tramway_cell do %>
Another
<% end %>
<% end %>
<% end %>tramway_table accepts the same optional options hash as Tailwinds::TableComponent. The hash is forwarded as HTML
attributes, so you can pass things like id, data attributes, or additional classes. If you do not supply your own width
utility (e.g. a class that starts with w-), the component automatically appends w-full to keep the table responsive. This
allows you to extend the default styling without losing the sensible defaults provided by the component.
Use the optional href: argument on tramway_row to turn an entire row into a link. Linked rows gain pointer and hover styles
(cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700) to indicate interactivity.
<%= tramway_table class: 'max-w-3xl border border-gray-200', data: { controller: 'table' } do %>
<%= tramway_header', headers: ['Name', 'Email'] %>
<%= tramway_row href: user_path(user) do %>
<%= tramway_cell do %>
<%= user.name %>
<% end %>
<%= tramway_cell do %>
<%= user.email %>
<% end %>
<% end %>
<% end %>When you render a header you can either pass the headers: array, as in the examples above, or render custom header content in
the block. Tailwinds::Table::HeaderComponent uses the length of the headers array to build the grid if the array is present.
If you omit the array and provide custom content, pass the columns: argument so the component knows how many grid columns to
generate.
<%= component 'tailwinds/table/header', columns: 4 do %>
<%= tramway_cell do %>
Custom header cell
<% end %>
<%= tramway_cell do %>
Another header cell
<% end %>
<!-- ... -->
<% end %>With this approach you control the header layout while still benefiting from the default Tailwind grid classes that the header component applies.
Tramway Buttons and Containers
Tramway ships with helpers for common UI patterns built on top of Tailwind components.
-
tramway_buttonrenders a button-styled form submit by default and acceptspath, optionaltext, HTTPmethod, and styling options such ascolor,type, andsize. It uses Rails'button_tohelper by default (or whenlink: falseis passed), and switches tolink_towhen you setlink: true.<%= tramway_button path: user_path(user), text: 'Open profile', link: true %>
All additional keyword arguments are forwarded to the underlying component as HTML attributes.
The
typeoption maps semantic intent to Tailwind color families. The full set of supported values is:Type Color default,lifeGray primary,hopeBlue secondaryZinc success,willGreen warning,greedOrange danger,rageRed loveViolet compassionIndigo fearYellow If none of the predefined semantic types fit your needs, you can supply a Tailwind color family directly using the
coloroption—for example:color: :gray. When you pass a custom color ensure the corresponding utility classes exist in your Tailwind configuration. Add the following safelist entries (adjusting the color name as needed) toconfig/tailwind.config.js:// config/tailwind.config.js module.exports = { // ... safelist: [ // existing entries … { pattern: /(bg|hover:bg|dark:bg|dark:hover:bg)-gray-(500|600|700|800)/, }, ], }
Tailwind will then emit the
bg-gray-500,hover:bg-gray-700,dark:bg-gray-600, anddark:hover:bg-gray-800classes that Tramway buttons expect when you opt into a custom color.<%= tramway_button path: user_path(user), text: 'View profile', color: :emerald, data: { turbo: false } %>
-
tramway_back_buttonrenders a standardized "Back" link.<%= tramway_back_button %>
-
tramway_containerwraps content in a responsive, narrow layout container. Pass anidif you need to target the container with JavaScript or CSS.<%= tramway_container id: 'user-settings' do %> <h2 class="text-xl font-semibold">Settings</h2> <p class="mt-2 text-gray-600">Update your preferences below.</p> <% end %>
Tailwind-styled forms
Tramway uses Tailwind by default. All UI helpers are implemented with ViewComponent.
tramway_form_for
Tramway provides tramway_form_for helper that renders Tailwind-styled forms by default.
<%= tramway_form_for @user do |f| %>
<%= f.text_field :text %>
<%= f.email_field :email %>
<%= f.password_field :password %>
<%= f.select :role, [:admin, :user] %>
<%= f.multiselect :permissions, [['Create User', 'create_user'], ['Update user', 'update_user']] %>
<%= f.file_field :file %>
<%= f.submit 'Create User' %>
<% end %>will render this
Available form helpers:
- text_field
- email_field
- password_field
- file_field
- select
- multiselect (Stimulus-based)
- submit
Examples
- Sign In Form for
deviseauthentication
app/views/devise/sessions/new.html.erb
<%= tramway_form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4') } do |f| %>
<%= component 'forms/errors', record: resource %>
<%= f.text_field :email, placeholder: 'Your email' %>
<%= f.password_field :password, placeholder: 'Your password' %>
<%= f.submit 'Sign In' %>
<% end %>- Sign In Form for Rails authorization
app/views/sessions/new.html.erb
<%= form_with url: login_path, scope: :session, local: true, builder: Tailwinds::Form::Builder do |form| %>
<%= form.email_field :email %>
<%= form.password_field :password %>
<%= form.submit 'Log in' %>
<% end %>Stimulus-based inputs
tramway_form_for provides Tailwind-styled Stimulus-based custom inputs.
Multiselect
In case you want to use tailwind-styled multiselect this way
<%= tramway_form_for @user do |f| %>
<%= f.multiselect :permissions, [['Create User', 'create_user'], ['Update user', 'update_user']] %>
<%# ... %>
<% end %>you should add Tramway Multiselect Stimulus controller to your application.
Example for importmap-rails config
config/importmap.rb
pin '@tramway/multiselect', to: 'tramway/multiselect_controller.js'app/javascript/controllers/index.js
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
import { Multiselect } from "@tramway/multiselect" // importing Multiselect controller class
eagerLoadControllersFrom("controllers", application)
application.register('multiselect', Multiselect) // register Multiselect controller class as `multiselect` stimulus controllerUse Stimulus change action with Tramway Multiselect
<%= tramway_form_for @user do |f| %>
<%= f.multiselect :role, data: { action: 'change->user-form#updateForm' } %>
<% end %>Tailwind-styled pagination for Kaminari
Tramway uses Tailwind by default. It has tailwind-styled pagination for kaminari.
How to use
Gemfile
gem 'tramway'
gem 'kaminari'config/initializers/tramway.rb
Tramway.configure do |config|
config.pagination = { enabled: true } # enabled is false by default
endapp/views/users/index.html.erb
<%= paginate @users %> <%# it will render tailwind-styled pagination buttons by default %>Pagination buttons looks like this
behave_as_ar
Tramway Decorator and Tramway Form support behave_as_ar method. It allows to use update and destroy methods with decorated and form objects.
object method
Tramway Decorator and Tramway Form have public object method. It allows to access ActiveRecord object itself.
user_1 = tramway_decorate User.first
user_1.object #=> returns pure user object
user_2 = tramway_form User.first
user_2.object #=> returns pure user objectConfiguration
Custom layout
In case you wanna use a custom layout:
- Create a controller
- Set the layout there
- Set this controller as
application_controllerin Tramway initializer - Reload your server
Example
app/controllers/admin/application_controller.rb
class Admin::ApplicationController < ApplicationController
layout 'admin/application'
endconfig/initializers/tramway.rb
Tramway.configure do |config|
config.application_controller = 'Admin::ApplicationController'
endArticles
- Tramway on Rails
- Tramway is the way to deal with little things for Rails developers
- Delegating ActiveRecord methods to decorators in Rails
- Behave as ActiveRecord. Why do we want objects to be AR lookalikes?
- Decorating associations in Rails with Tramway
- Easy-to-use Tailwind-styled multi-select built with Stimulus
Contributing
Install lefthook
make install
License
The gem is available as open source under the terms of the MIT License.