Creates presenter objects for view formatting using SimpleDelegator pattern with TDD. Use when extracting view logic from models, formatting data for display, creating badges/labels, or when user mentions presenters, view models, formatting, or display helpers.
Creates presenters that wrap models for view-specific formatting with tests first.
test/presenters/BasePresenterPresenters in this project:
BasePresenter < SimpleDelegator# app/presenters/base_presenter.rb
class BasePresenter < SimpleDelegator
include ActionView::Helpers::NumberHelper
include ActionView::Helpers::DateHelper
include ActionView::Helpers::UrlHelper
include ActionView::Helpers::TagHelper
include ActionView::Helpers::TextHelper
def initialize(model, view_context = nil)
super(model)
@view_context = view_context
end
def model
__getobj__
end
alias_method :object, :model
end
# test/presenters/event_presenter_test.rb
require "test_helper"
class EventPresenterTest < ActiveSupport::TestCase
setup do
@event = events(:one)
@presenter = EventPresenter.new(@event)
end
test "delegates to the model" do
assert_equal @event.name, @presenter.name
end
test "responds to model methods" do
assert_respond_to @presenter, :name
assert_respond_to @presenter, :status
assert_respond_to @presenter, :created_at
end
test "exposes the underlying model" do
assert_equal @event, @presenter.model
end
test "#display_name returns the formatted name" do
assert_equal @event.name, @presenter.display_name
end
test "#formatted_date returns formatted date when present" do
@event.update(event_date: Date.new(2026, 7, 15))
result = @presenter.formatted_date
assert_includes result, "2026"
end
test "#formatted_date returns placeholder when nil" do
@event.update(event_date: nil)
result = @presenter.formatted_date
assert_includes result, "text-slate-400"
assert_includes result, "italic"
end
test "#status_badge returns HTML-safe string" do
assert_predicate @presenter.status_badge, :html_safe?
end
test "#status_badge includes status text" do
assert_includes @presenter.status_badge, @event.status.humanize
end
test "#status_badge uses correct color for active" do
@event.update(status: :active)
presenter = EventPresenter.new(@event)
assert_includes presenter.status_badge, "bg-green-100"
end
test "#status_badge uses correct color for inactive" do
@event.update(status: :inactive)
presenter = EventPresenter.new(@event)
assert_includes presenter.status_badge, "bg-red-100"
end
test "#formatted_amount formats cents as currency" do
@event.update(amount_cents: 15000)
assert_equal "150,00 EUR", @presenter.formatted_amount
end
end
bin/rails test test/presenters/event_presenter_test.rb
# app/presenters/event_presenter.rb
class EventPresenter < BasePresenter
STATUS_COLORS = {
active: "bg-green-100 text-green-800",
inactive: "bg-red-100 text-red-800",
pending: "bg-yellow-100 text-yellow-800"
}.freeze
DEFAULT_COLOR = "bg-slate-100 text-slate-800"
def display_name
name
end
def formatted_date
return not_specified_span if event_date.nil?
I18n.l(event_date, format: :long)
end
def status_badge
tag.span(
status_text,
class: "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium #{status_color}"
)
end
def formatted_amount
return "0,00 EUR" if amount_cents.nil? || amount_cents.zero?
number_to_currency(
amount_cents / 100.0,
unit: "EUR",
separator: ",",
delimiter: " ",
format: "%n %u"
)
end
private
def status_text
I18n.t("activerecord.attributes.event.statuses.#{status}", default: status.to_s.humanize)
end
def status_color
STATUS_COLORS.fetch(status.to_sym, DEFAULT_COLOR)
end
def not_specified_span
tag.span(
I18n.t("presenters.common.not_specified"),
class: "text-slate-400 italic"
)
end
end
bin/rails test test/presenters/event_presenter_test.rb
def formatted_event_date
return not_specified_span if event_date.nil?
I18n.l(event_date, format: :long)
end
def short_date
return "\u2014" if event_date.nil?
event_date.strftime("%d/%m/%Y")
end
def formatted_budget
return not_specified_span if budget_cents.nil?
number_to_currency(
budget_cents / 100.0,
unit: "EUR",
separator: ",",
delimiter: " ",
format: "%n %u",
precision: 0
)
end
def type_badge
tag.span(
display_type,
class: "inline-flex items-center px-2 py-1 rounded text-xs font-medium #{type_color}"
)
end
def display_email
return not_specified_span if email.blank?
mail_to(email, email, class: "text-blue-600 hover:underline")
end
def display_phone
return not_specified_span if phone.blank?
link_to(phone, "tel:#{phone}", class: "text-blue-600 hover:underline")
end
# Single resource
@event = EventPresenter.new(@event)
# Collection
@events = events.map { |e| EventPresenter.new(e) }
# With view context (for route helpers)
@event = EventPresenter.new(@event, view_context)
BasePresenterhtml_safenot_specified_span for nil values