Real-world patterns
Conventions distilled from a range of production Compony apps. These are idioms, not
framework requirements — but they recur consistently and are worth adopting. Every example
uses a neutral domain (Account, Order, LineItem, Document). Where a pattern relies
on a companion gem (CanCanCan, ActiveType, simple_form, a date/select input) that is
called out.
For exact method signatures see dsl_reference.md; for footguns see gotchas.md.
1. The app base-component layer
Almost every non-trivial app inserts one abstract layer between Compony's pre-built components and the concrete ones. Concrete components inherit from the app layer, never from Compony directly. This centralizes layout, button styling, and chrome so the whole app's look changes in one place.
# app/components/base_components/show.rb (a common location; app/compony/ is also used)
module BaseComponents
class Show < Compony::Components::Show
setup do
standalone { layout :backend } # app-wide Rails layout for all non-publicly accessible components
(:icon) { :eye }
content :main, hidden: true # concrete comps fill :main…
content :wrapper do # …chrome lives here, inherited
div class: 'card card-body' do
content :main
end
end
end
end
end
# app/components/orders/show.rb
class Components::Orders::Show < BaseComponents::Show
end # fully functional, empty body
Recurring forms of this layer: BaseComponents::{Index,Show,New,Edit,Destroy,List}. The
content :main, hidden: true + content :wrapper pair is the standard way to let
children override the inner content while inheriting the outer chrome (see
basic_component.md).
Teams sometimes add their own helper DSL on top of this layer (CSV/PDF helpers, archive toggles, etc.). Keep such helpers in the app base layer, not in concrete components.
2. Thin leaf components
Concrete CRUD components are usually empty — all behavior is inherited. Add a setup block
only to deviate.
class Components::Orders::Destroy < BaseComponents::Destroy; end
class Components::Orders::New < BaseComponents::New; end
class Components::Orders::Edit < BaseComponents::Edit; end
This is the single most common pattern. Prefer it over hand-written endpoints (gotchas.md #15).
3. Index = load_data scope + nested :list
Index components rarely render rows themselves; they load a scope and embed the family's
List via render_sub_comp.
class Components::Orders::Index < BaseComponents::Index
setup do
load_data { @data = Order.accessible_by(current_ability).order(created_at: :desc) }
content do
h1 Order.model_name.human(count: 2)
concat render_sub_comp(:list, @data)
end
end
end
accessible_by(current_ability)is the CanCanCan scoping idiom — pair it with theauthorizeblock so list and access rules agree.concatis mandatory aroundrender_sub_comp/render_intent(gotchas.md #2).
4. List customization
This pattern is typically combined with a customized BaseComponents::List that adds styling and
features to the pre-built list component.
class Components::Orders::List < BaseComponents::List
setup do
columns :number, :customer, as_title: true # as_title -> card title on mobile
columns :total, :created_at
column :status do |order| # computed/custom cell
span order.status.label, class: "badge bg-#{order.status.key}"
end
filters :number, :status
sorts :number, :created_at
default_sorting 'created_at desc'
end
end
Embedding a child list inside a Show, dropping the redundant FK column and preserving the active tab across filter submits:
concat render_sub_comp(:list, @data.line_items, skip_columns: [:order],
params_in_filter: [param_name('tab')])
skip_* options (skip_pagination:, skip_filtering:, skip_columns:, …) are
constructor kwargs passed through render_sub_comp, useful for read-only embeds.
5. Custom form + Schemacop, kept in sync
form_fields (rendering) and schema_* (param whitelist) must mirror each other.
class Components::Orders::Form < Compony::Components::Form
setup do
form_fields do
concat field(:number)
concat field(:customer, as: :tom_select) # association name, not _id
concat field(:placed_at, as: :flatpickr_datetime)
concat pw_field(:access_code)
concat field(:internal_ref, hidden: true) # submitted, not shown
div class: 'row' do # arbitrary Dyny layout
div field(:first_name), class: 'col'
div field(:last_name), class: 'col'
end
end
schema_fields :number, :customer, :placed_at, :internal_ref
schema_pw_field :access_code
end
end
as: :tom_select/as: :flatpickr_date(time)are app-registered simple_form inputs (TomSelect, Flatpickr) — a good choice for selects and date pickers.- Use the association name in
field/schema_field;_idis added automatically (gotchas.md #4). - Nested attributes:
f.simple_fields_for(:line_items)inform_fieldsplus a rawschema_line { ary? :line_items_attributes do ... end }. - Multilang fields:
field(:title, multilang: true).each { |i| concat i }paired withschema_field :title, multilang: true.
Wire a non-default form into New/Edit with form_comp_class:
class Components::Orders::QuickAdd < Compony::Components::New
setup { form_comp_class Components::Orders::QuickAddForm }
end
6. Autocomplete form (app-level subclass)
Compony does not ship autocomplete, but a very common app pattern is an
AutocompleteForm base (subclass of Compony::Components::Form) exposing an extra
standalone JSON endpoint for an ajax select. Shape:
class BaseComponents::AutocompleteForm < Compony::Components::Form
# class-level `autocomplete(field) { |query, ability| ...collection... }` that
# registers an extra `standalone :autocomplete_<field>` returning
# [{ text:, value:, icon: }] JSON, consumed by a TomSelect Stimulus controller.
end
class Components::Orders::Form < BaseComponents::AutocompleteForm
setup do
form_fields { concat field(:customer, as: :tom_select) }
schema_field :customer
autocomplete(:customer) { |q, ability| Customer.accessible_by(ability).search(q) }
end
end
If you need autocomplete, build this base once and reuse it.
7. Tabbed Show via a mixin
Detail pages are split into tabs with a small app mixin that adds a tab DSL and renders
a tab bar into :main. Each tab body typically renders content :data or a nested list.
class Components::Orders::Show < BaseComponents::Show
include ComponentMixins::Tabs
setup do
tab(:overview, _('Overview')) { content :data }
tab(:items, _('Items')) { concat render_sub_comp(:list, @data.line_items,
skip_columns: [:order]) }
end
end
The mixin keys the active tab off a prefixed param (param_name('tab')) so multiple
tabbed components can coexist. Compony has no built-in tabs — copy the mixin per app.
8. Lifecycle hooks for derived data
after_assign_attributes— fill defaults / context after params are assigned, before validation:@data.account_id ||= current_user.account_id.before_render— verb-independent guards and precomputation. Redirect and the content chain is skipped:ruby before_render do redirect_to Compony.path(:show, @data) if @data.locked? endload_data— narrow the scope (accessible_by,includes, ordering).store_data— override persistence (virtual models, file handling, bulk import).on_{created,updated,destroyed}_redirect_path— control where success lands, e.g.Compony.path(:show, @data.parent)for owned records.
9. Exposed intents as the action toolbar
Concrete components tailor the header toolbar by add/remove on inherited intents.
exposed_intents do
remove :destroy
add :show, @data, label: 'PDF', name: :pdf, path: { format: :pdf },
feasibility_action: :pdf
add :archive, @data, method: :patch, before: :destroy
end
path: { format: :pdf }points a button at a format endpoint (see pattern 10).feasibility_action:ties the button's enabled state to a modelprevent(feasibility.md).- State-dependent toolbars (archived vs active) are done by branching inside the
exposed_intentsblock on@data. - Generating one intent per enum value is common:
Period.all.each { |p| add :new, :prices, name: :"new_#{p.key}", path: { price: { period: p.key } } }.
10. CSV / PDF via respond :format
A format export is the same component with an extra respond branch and an exposed intent
pointing at it. Because overriding respond skips the default authorize, re-check there
(gotchas.md #3).
standalone path: 'orders' do
verb :get do
{ can?(:read, Order) }
respond :csv do
can?(:read, Order) or raise CanCan::AccessDenied
send_data(OrderCsv.new(@data).to_csv, filename: 'orders.csv', type: 'text/csv')
end
respond :pdf do
can?(:read, @data) or raise CanCan::AccessDenied
send_data(OrderPdf.new(@data).render, filename: @data.pdf_name,
type: 'application/pdf')
end
end
end
# exposed_intents { add :index, :orders, label: 'CSV', path: { format: :csv } }
11. Non-CRUD: job dispatch, toggles, clone
Job dispatch — POST-only custom component, enqueue, flash, redirect:
class Components::Orders::ScheduleSync < Compony::Component
setup do
standalone path: 'orders/schedule_sync' do
verb :post do
{ can?(:create, Order) }
respond do
SyncOrdersJob.perform_later
flash.notice = _('Queued — give it a few minutes.')
redirect_to Compony.path(:index, :orders)
end
end
end
label(:all) { _('Sync now') }
(:icon) { :rotate }
end
end
Expose it from Index: exposed_intents { add :schedule_sync, :orders, method: :post }.
State toggle — inherit Edit, flip in after_assign_attributes, dynamic label:
class Components::Accounts::ToggleActive < Compony::Components::Edit
setup do
standalone path: 'accounts/:id/toggle_active' do
verb :patch do { can?(:toggle_active, @data) } end
end
label(:long) { |a| a.active? ? _('Deactivate') : _('Activate') }
after_assign_attributes { @data.active = !@data.active }
end
end
Clone — inherit New, load + dup the source in load_data, redirect to the copy:
class Components::Orders::Clone < Compony::Components::New
setup do
standalone path: 'orders/:id/clone'
load_data do
source = Order.find(params[:id])
(:read, source) # CanCanCan bang form
@data = source.dup
end
on_created_redirect_path { Compony.path(:show, @data) }
end
end
12. Virtual model for non-persistent / upload forms
Inherit New, back it with a Compony::VirtualModel, take over the response. @data.save
is a no-op so business logic goes in on_created_respond (or store_data).
class Components::Documents::Import < Compony::Components::New
class VirtualModel < Compony::VirtualModel
attribute :id, :bigint
belongs_to :account
has_one_attached :file
field :account, :association
field :file, :attachment
validates :file, presence: true
end
setup do
standalone path: 'documents/import'
data_class VirtualModel
form_comp_class Components::Documents::ImportForm
# ActiveStorage on a virtual model: validate only, read the tempfile yourself.
store_data do
@create_succeeded = @data.validate
next unless @create_succeeded
tempfile = params.dig(:documents_virtual_model, :file)&.tempfile
DocumentImporter.call(account: @data.account, io: tempfile)
end
on_created_respond do
flash.notice = _('Imported.')
redirect_to Compony.path(:index, :documents)
end
end
end
See virtual_models.md and gotchas.md #12.
13. Public endpoints & webhooks
class Components::Public::Webhook < Compony::Component
setup do
standalone path: '/webhooks/orders' do
skip_authentication!
skip_forgery_protection!
verb :post do
{ true } # still mandatory
respond do
expected = "Bearer #{ENV.fetch('WEBHOOK_TOKEN')}"
got = request.headers['Authorization'].to_s
unless ActiveSupport::SecurityUtils.secure_compare(got, expected)
sleep 1 # crude timing equalization
next controller.head(:unauthorized)
end
OrderWebhook.process!(request.params)
controller.head :accepted
end
end
end
end
end
A login-aware redirect splitter is the same shape with verb :get + before_render
choosing a Compony.path by current_user.
14. Custom button style
Register one app button style and refer to it everywhere via style:.
class Components::Commons::BootstrapButton < Compony::Components::Buttons::Link
protected
def prepare_opts!
super
classes = (@comp_opts[:class] || '').split
classes << 'btn' << "btn-#{@comp_opts[:color] || :primary}"
@comp_opts[:class] = classes.join(' ')
end
end
# config/initializers/compony.rb
# Compony.register_button_style :bootstrap, '::Components::Commons::BootstrapButton'
# Compony.default_button_style = :bootstrap
Make a separate style per visual kind (dropdown item, pill, compact) and select with
render_intent(:show, @data, style: :compact).
15. Inline-edit card with a Turbo Frame
A Show panel where the Edit form swaps in place (no full-page nav) and swaps back on save.
Wrap both the Show content and the Edit form in a same-named turbo_frame_tag; Turbo
Drive then scopes navigation to that frame. Distinct from the render_sub_comp(:list, …,
turbo_frame:) use in nesting.md (there the frame isolates a
nested list's own search/filter params; here it is the inline-edit boundary for one
record's Show/Edit pair).
# One frame name shared by the Show panel and the Edit form.
def card_frame(record) = :"#{record.model_name.singular}_#{record.id}_card"
class Components::Accounts::Show < Compony::Components::Show
setup do
content :data do
turbo_frame_tag card_frame(@data) do # Dyny: Rails view helper
# …render fields…
concat render_intent(:edit, @data, label: { format: :short })
end
end
end
end
class Components::Accounts::Edit < Compony::Components::Edit
setup do
content do
turbo_frame_tag card_frame(@data) do # same frame name
concat form_comp.render(controller, data: @data)
end
end
# Default on_updated_redirect_path → Show; Turbo replaces just the frame.
end
end
- Frame name must match exactly; deriving it from the record id keeps it unique when several cards render on one page.
- A failed save re-renders Edit with HTTP 422 — keep the
turbo_frame_tagwrapper in the Edit content so errors render in-frame too.
16. Multi-step wizard across components
A create/edit flow split over several steps, each its own component, advancing on save.
Chain steps with on_updated_redirect_path (or on_created_redirect_path) and render a
step indicator via a shared mixin (same mechanism as the tabs mixin in §7).
module OrderWizard
extend ActiveSupport::Concern
STEPS = %i[details_edit shipping_edit confirm_edit].freeze
included do
setup do
content :wizard_nav, before: :main do
ol class: 'wizard' do
OrderWizard::STEPS.each do |step|
li step.to_s.delete_suffix('_edit'),
class: (component.comp_name.to_sym == step ? 'active' : nil)
end
end
end
end
end
end
class Components::Orders::DetailsEdit < Compony::Components::Edit
include OrderWizard
setup do
standalone path: 'orders/:id/details'
on_updated_redirect_path { Compony.path(:shipping_edit, @data) } # → next step
end
end
class Components::Orders::ShippingEdit < Compony::Components::Edit
include OrderWizard
setup do
standalone path: 'orders/:id/shipping'
on_updated_redirect_path { Compony.path(:confirm_edit, @data) }
end
end
# …ConfirmEdit redirects to Show when done.
- Each step is a normal resourceful component on the same model — partial validation per
step is just per-step
schema_fields in each step's Form. - For a non-persistent wizard (nothing saved until the end), back the components with a VirtualModel and carry state in its attributes (§12).
comp_namedrives the active-step highlight, so the mixin needs no per-step config.
17. Inline PATCH without a form (reorder / quick toggle)
A JS front-end (drag-to-sort, an inline checkbox) issues a small PATCH that mutates state
and returns no body. Add a named extra standalone with verb :patch, validate with
Schemacop directly, and head :ok. No Form component involved.
class Components::Orders::Show < Compony::Components::Show
setup do
# Main route inherited from Show. Companion endpoint for reordering line items:
standalone :reorder, path: 'orders/:id/reorder' do
verb :patch do
{ can?(:update, @data) }
respond do # overriding respond skips default authorize…
can?(:update, @data) or raise CanCan::AccessDenied # …so re-check here
params = Schemacop::Schema3.new(:hash) do
ary! :ordered_ids do
list :integer
end
end.validate!(controller.request.params)
@data.line_items.reorder_by!(params[:ordered_ids])
controller.head :ok
end
end
end
end
end
The route is reorder_show_orders_comp (see
standalone naming); point your
Stimulus controller's PATCH at
Compony.path(:show, @data, standalone_name: :reorder).
- This is the gotchas.md #3
case: the custom
respondreplaces the default that runsauthorize, so authorize again inside it. - Keep companion endpoints in the same component as the screen they serve — what extra
named
standalones are for (standalone.md), not a reason for a new component. - Return
head :ok(or small JSON) — no Compony content to render for an ajax-only verb.
18. Signed-token capability links (auth-less onboarding / magic links)
Goal: an emailed link that lets an unauthenticated visitor perform one bounded action —
invite acceptance, magic login, password reset, email confirmation — without a session.
The trick: override Compony's path do … end to mint a signed JWT and carry it as a
token query param, then gate a skip_authentication! standalone with
authorize { token_valid?(params) }. A small mixin centralizes encode/decode.
Security — read before copying. Such a link is the capability; anyone holding the URL can perform the action. It is only safe if every one of these holds:
- Expiry is mandatory. Put
expin the payload and verify it. A capability link without a TTL is a permanent account-takeover primitive (it leaks via referrer headers, proxy logs, mail forwarding, browser history). Pair short TTLs with a resend flow.- Pin the algorithm and verify the signature —
JWT.decode(token, secret, true, { algorithm: 'HS512' }). Never acceptalg: none; never leave verification off.- Fail closed. Rescue
JWT::DecodeError(its subclasses cover bad signature, malformed token and expiry) and returnnil/falsesoauthorizedenies with 403 — not a 500.- Use a dedicated signing secret, not
secret_key_base, so rotating it doesn't also invalidate every session (and vice-versa).- Still provide an
authorizeblock:skip_authentication!removes authentication, not authorization (gotchas.md #14).
# app/component_mixins/with_token.rb
module WithToken
extend ActiveSupport::Concern
TOKEN_TTL = 14.days
def encode_token(payload)
JWT.encode(payload.merge(exp: TOKEN_TTL.from_now.to_i), token_secret, 'HS512')
end
# Memoized; returns the payload (indifferent access) or nil. Fails closed.
def token_data(params = nil)
return @token_data if @token_data
return nil if params.blank?
@token = params[:token]
return nil if @token.blank?
@token_data = JWT.decode(@token, token_secret, true, { algorithm: 'HS512' })
.first.with_indifferent_access
rescue JWT::DecodeError # bad sig / malformed / expired — all subclasses
nil
end
def token_secret
Rails.application.credentials.capability_token_secret.presence ||
Rails.application.credentials.secret_key_base # fallback until set
end
end
Override path so links self-mint a token (callers pass the subject, not the token):
class Components::Invites::Accept < Compony::Components::New
include WithToken
class VirtualModel < Compony::VirtualModel
attribute :password, :string
attribute :account_id # carried for validation only
field :password, :string
def label = 'Invite'
end
setup do
# Building a path to this component mints the token from the given account.
path do |*args, account: nil, token: nil, **kwargs|
if token.blank?
fail('Missing kwarg :account in path') if account.nil?
token = encode_token(account_id: account.id)
end
next Rails.application.routes.url_helpers
.send("#{path_helper_name}_path", *args, token:, **kwargs)
end
standalone path: '/invites/accept' do
skip_authentication!
verb :get do { token_valid?(params) } end
verb :post do { token_valid?(params) } end
end
data_class VirtualModel
form_cancancan_action nil
submit_path { Compony.path(self.class, @data, token: @token) }
after_assign_attributes { @data.account_id = token_data(params)[:account_id] }
store_data do
@create_succeeded = @data.validate
next unless @create_succeeded
Account.find(token_data[:account_id]).update!(password: @data.password)
end
on_created_respond { redirect_to Compony.path(:show, :sessions) }
end
# Shape-check the decoded payload; anything off → false → 403 (never 500).
def token_valid?(params)
data = token_data(params)
return false if data.blank?
Schemacop::Schema3.new(:hash) do
int! :account_id, cast_str: true
int? :exp
end.validate!(data)
true
rescue Schemacop::Exceptions::ValidationError
false # token signed for a different flow
end
end
Notes:
Compony.path(:accept, :invites, account: some_account)returns the full tokenized URL — email that. The token, not a session, authorizes the request.path doruns outside the request context; build URLs viaRails.application.routes.url_helpers, notcontroller/helpers(see standalone.md).- Reuse the mixin for every link flow (magic login, password reset, email confirm); encode a flow discriminator or rely on the per-component payload shape-check to stop a token minted for one flow being replayed against another.
- One signed boolean in the payload (e.g.
confirmed: true) is tamper-proof since the client cannot re-sign — handy for multi-hop confirm flows.
Good habits
- CanCanCan everywhere:
authorize { can?(...) }, scope withModel.accessible_by(current_ability), bang formauthorize!(:read, record)for ad-hoc checks inload_data. - Always
Compony.path/render_intent, never hardcoded routes orbutton_to(gotchas.md #11, #15). - Place a resourceful component in the family of the model it acts on, not the family it is reached from; pass parent context via path params.
- Keep virtual/form-only fields off models — use ActiveType/VirtualModel (gotchas.md #16).
concataround everyrender_intent/render_sub_comp/fieldin a block.