Advanced example
example.md shows the basics. This one combines patterns you hit in
real apps: a custom form, feasibility, exposed intents, a CSV export endpoint, a
before_render guard, and a virtual-model launch form for a background job.
Domain: invoicing. An Invoice has line items, can be locked, exported as CSV, and a
background "send reminders" job can be launched from a non-persistent form.
The models
# app/models/invoice.rb
class Invoice < ApplicationRecord
has_many :line_items, dependent: :destroy
accepts_nested_attributes_for :line_items, allow_destroy: true
belongs_to :customer
field :number, :string
field :customer, :association
field :total, :decimal
field :locked, :boolean
field :issued_at, :datetime
def label = number
# Feasibility: a locked invoice must not be edited or destroyed.
# Back every important prevention with a real validation (see note in feasibility.md).
prevent %i[edit destroy], 'the invoice is locked' do
locked?
end
validate { errors.add(:base, 'invoice is locked') if locked_was && locked? }
end
# app/models/line_item.rb
class LineItem < ApplicationRecord
belongs_to :invoice
field :description, :string
field :amount, :decimal
def label = description
end
Index with a CSV export endpoint and exposed intents
One component, two routes: the HTML list and a .csv download. The CSV intent is exposed
so the layout renders a "Download CSV" button in the page header.
class Components::Invoices::Index < Compony::Component
include Compony::ComponentMixins::Resourceful
setup do
label(:all) { 'Invoices' }
standalone path: 'invoices' do
verb :get do
{ can?(:read, Invoice) }
# Default (HTML) response renders content. CSV gets its own response:
respond :csv do
= can?(:read, Invoice) or raise CanCan::AccessDenied # respond skips authorize!
send_data(InvoiceCsv.new(@data).to_csv, filename: 'invoices.csv', type: 'text/csv')
end
end
end
load_data { @data = Invoice.accessible_by(current_ability).order(issued_at: :desc) }
exposed_intents do
add :index, :invoices, label: 'Download CSV', name: :csv, path: { format: :csv }
add :request_reminders, :invoices, label: 'Send reminders', method: :get
end
content do
concat render_intent(:new, :invoices, button: { label: { format: :short } })
concat render_sub_comp(:list, @data)
end
end
end
Notes:
respond :csvhandlesGET /invoices.csv. Because overridingrespondskips the defaultauthorize(see gotchas.md), the CSV branch re-checks the ability itself.path: { format: :csv }on the exposed intent makes its button point at.csv.- The layout renders exposed intents of
Compony.root_comp— see intents.md.
A custom Form with nested line items
Edit/New look for Components::Invoices::Form by default. We write it with
accepts_nested_attributes_for via simple_form's simple_fields_for, and whitelist the
nested params with a raw schema_line.
class Components::Invoices::Form < Compony::Components::Form
setup do
form_fields do
concat field(:number)
concat field(:customer, as: :tom_select) # association name, NOT customer_id
concat field(:issued_at, as: :flatpickr_datetime)
concat(f.simple_fields_for(:line_items) do |lf|
concat lf.input(:description)
concat lf.input(:amount)
end)
end
schema_fields :number, :customer, :issued_at
# Nested attributes need a manual Schemacop line:
schema_line do
ary? :line_items_attributes do
list :hash do
int? :id
str? :description
num? :amount
boo? :_destroy
end
end
end
end
end
schema_field :customer (association name) lets Compony add customer_id automatically —
passing :customer_id here would not work
(gotchas.md).
Edit: thin, plus a guard and a custom redirect
Edit inherits all CRUD wiring. We only add a before_render guard (independent of HTTP
verb) and override where a successful save lands.
class Components::Invoices::Edit < Compony::Components::Edit
setup do
before_render do
if @data.locked?
flash.alert = 'This invoice is locked.'
redirect_to Compony.path(:show, @data)
end
end
on_updated_redirect_path { Compony.path(:show, @data) }
end
end
The prevent :edit in the model already greys out the edit button with a tooltip; the
before_render guard plus the model validation stop a hand-crafted request
(feasibility.md explains why all three layers are needed).
Components::Invoices::New, Show, Destroy can stay empty — the pre-built parents do
the work.
A virtual-model launch form for a background job
"Send reminders" should pop a small form (which customers? how many days overdue?) and, on
submit, queue a job — nothing is persisted. Inherit New, back it with a
Compony::VirtualModel, and take over on_created_respond.
class Components::Invoices::RequestReminders < Compony::Components::New
class VirtualModel < Compony::VirtualModel
attribute :min_days_overdue, :integer, default: 7
attribute :only_locked, :boolean, default: false
field :min_days_overdue, :integer
field :only_locked, :boolean
validates :min_days_overdue, numericality: { greater_than: 0 }
def label = 'Reminder request'
end
class Form < Compony::Components::Form
setup do
form_fields do
concat field(:min_days_overdue)
concat field(:only_locked)
end
schema_fields :min_days_overdue, :only_locked
end
end
setup do
standalone path: 'invoices/request_reminders' # avoid clashing with the New route
data_class VirtualModel
form_comp_class Components::Invoices::RequestReminders::Form
label(:all) { 'Send reminders' }
# @data.save is a no-op (virtual). on_created_respond fires only after validations pass.
on_created_respond do
SendRemindersJob.perform_later(min_days_overdue: @data.min_days_overdue,
only_locked: @data.only_locked)
flash.notice = 'Reminders queued — give it a few minutes.'
redirect_to Compony.path(:index, :invoices)
end
end
end
Why it works: inheriting New gives the new/create flow. On submit Compony validates,
re-renders the form with errors on failure, otherwise calls @data.save (a no-op on a
virtual model) and runs on_created_respond, where you regain control. For ActiveStorage
on a virtual model, also override store_data to only validate — see
virtual_models.md.