transition_buttons
Render one button per state-machine event the current user may perform on a
record right now — the intersection of legal from the current state (the
state machine's guards) and authorized for this user (your policy) — each
button POSTing to a member transition route that re-checks both
server-side before firing.
The buttons are a convenience for the view. The endpoint is the security boundary: it never trusts the submitted event, re-deriving the legal-and-authorized set independently.
Ships with adapters for state_machines
and ActionPolicy; both are swappable.
Install
# Gemfile
gem "transition_buttons"
gem "state_machines-activerecord"
gem "action_policy"
Usage
1. A model with one or more state machines
class Post < ApplicationRecord
state_machine :state, initial: :draft do
event(:submit) { transition draft: :pending_review }
event(:publish) { transition pending_review: :published }
event(:reject) { transition pending_review: :draft }
end
# A second machine on the same record is fully supported.
state_machine :moderation_state, initial: :ok do
event(:flag) { transition ok: :flagged }
event(:unflag) { transition flagged: :ok }
end
end
2. A policy whose rule names match the event names
class PostPolicy < ApplicationPolicy
def submit? = owner?
def publish? = editor?
def reject? = editor?
def flag? = editor?
def unflag? = editor?
end
An event with no matching rule is denied by default — adding an event can never silently expose it.
3. Route + controller
# config/routes.rb
resources :posts, only: [:index, :show] do
transitionable # POST /posts/:id/transition (transition_post)
end
class PostsController < ApplicationController
include TransitionButtons::Transitionable
# `transition` is provided. The record defaults to Post.find(params[:id]);
# override #transition_buttons_record for custom scoping.
end
4. The view
<%= transition_buttons_for(@post) %> <%# machine: :state %>
<%= transition_buttons_for(@post, machine: :moderation_state) %>
<%= transition_buttons_for(@post, user: some_user, class: "btn") %>
<%# Or render your own UI from the raw permitted list: %>
<% available_events(@post).each do |event| %> ... <% end %>
user: defaults to current_user when the view exposes it. Extra keyword
options are forwarded to each button_to.
Recording who triggered a transition
If your model exposes current_actor=, the controller sets it to current_user
before firing, so a transition log (e.g.
state_machines-audit_trail)
can record the actor:
class Post < ApplicationRecord
attr_accessor :current_actor
state_machine :state, initial: :draft do
audit_trail context: :audited_by
# ...
end
def audited_by = current_actor&.email
end
Swapping backends
TransitionButtons.configure do |c|
c.state_machine_adapter = MyAdapter.new # #available_events/#current_state/#fire/#event_label
c. = MyAuthorizer.new # #permitted?(user:, record:, event:, machine:)
c.default_machine = :state
end
An AASM adapter (record.aasm(machine).events(permitted: true) /
record.public_send("#{event}!")) and a Pundit authorizer are natural
additions and the seams are designed for them.
Out of scope (v1)
Events that need arguments — those want a real form, not a one-click button.