ERB → ViewModel: Extract Ruby logic out of an ERB template
When the user types /erb-to-view-model, migrate an ERB template away from instance variables / helper calls by precomputing all required values in a Ruby ViewModel under app/view_models/, following existing repo conventions.
Before proceeding, ask the user for these parameters (do not proceed until all are provided):
<erb-path> (example: app/views/groups/index.html.erb)<controller>#<action> (example: GroupsController#index)<view-model-constant> (example: Groups::IndexViewModel)<view-model-path> (example: app/view_models/groups/index_view_model.rb)This command always uses the template local variable name view_model (it is not configurable).
Also ask for these parameters (strongly recommended; do not proceed without tests):
<spec-path> (example: spec/controllers/groups_controller_characterization_spec.rb)<feature-flag> (example: groups_index_view_model_migration)This command assumes layout variants are always web-only.
Read ALL rules in:
@.cursor/rules/
In addition, these constraints are part of this command's prompt and must be followed:
ERB → ViewModel migration constraints
@something) with view_model.somethingcan?(:...) in ERB; replace with precomputed view_model.can_* flagscurrent_user.* in ERB; replace with precomputed view_model.current_user_* or derived flagspolymorphic_path / polymorphic links directly in ERB; precompute paths/urls on the ViewModelViewModels must be "precompute-first"
T::Struct with const values for anything the template readsself.init(...) that computes everything once, then new(...)has_attachments (not has_attachments?) for conditionalsT.nilable(...) and handle nil explicitlyNo direct model references inside ViewModels
User, Group, ChatThread) as const values in new/modified ViewModelsinit (do not serialize at render time)How views should consume ViewModels
&.) in templates for nilable ViewModel propertieshas_translation (not has_translation?)Controller integration must be thin
Refactor bottom-up
Use existing converted ViewModels as the primary examples (read before writing new code):
@app/view_models/groups/index_view_model.rb (nested VMs, helpers, permissions)@app/view_models/chats/message_show_view_model.rb (large precompute init, URLs, flags)@app/view_models/ for the closest match.If the ViewModel needs Rails helpers (paths, formatting), prefer the typed adapter pattern used in Groups:
@app/view_models/groups/helper_interface.rb@app/view_models/groups/helper_adapter.rbFollow @.cursor/rules/rspec/controller-characterization-test-golden-rule.mdc:
Heavily prefer a controller-level characterization spec to exist for <controller>#<action> before changing any ERB/ViewModel code.
If you cannot find an existing characterization spec for this controller/action:
@prompts/erb_characterization_tests.md<spec-path> (or the repo's established characterization spec location for that controller)Add or extend a controller-level characterization spec at <spec-path> (preferred: reuse an existing one)
Ensure it uses render_views
Assert against response.body HTML (not instance variables, not partial-level specs)
Cover the key states that are likely to regress during migration:
Run the spec and confirm it passes before changing the ERB or ViewModel code.
Follow @.cursor/rules/view_models/hierarchy-refactoring.mdc:
Trace the render tree
<erb-path>, list every rendered partial and the locals it expects.Build or extend ViewModels
<view-model-path> so it is a T::Struct with precomputed const valuesself.init(...) with explicit parameters (no default args)T.nilable(...) and pass nil explicitly from the callerOpenStructDo not add direct model references to new ViewModels
Update controller creation
<view-model-constant>.init(...).view_model:render :index, locals: { view_model: @view_model }
If the controller has multiple render branches (app vs web), keep them consistent in how they pass the ViewModel.
In <erb-path> and its partials:
@something → view_model.somethingpolymorphic_path(...) → view_model.some_pathsimple_format, auto_link, pluralization text, etc. → view_model.some_html / view_model.some_textcan?(...) → view_model.can_*current_user.* → view_model.current_user_* or other precomputed flagsif thing.present? → if view_model.has_thingrender partial: ..., collection: models → collection: view_model.child_view_models, as: :view_model&. in templates for nilable ViewModel properties (don't "tighten" callsites).<controller>#<action>.@variable access and no direct can? / current_user.* / polymorphic_path calls per erb/template-viewmodel-migration.mdc.ruby-no-default-params.mdc).Structure the response as:
## ERB → ViewModel migration
### Inputs
- ERB: <erb-path>
- Controller/action: <controller>#<action>
- ViewModel: <view-model-constant> (<view-model-path>)
- Template var: view_model
### Render tree
- [List partials, top to bottom]
### Changes made
- [File list + 1-line summary each]
### Rule compliance notes
- [Call out any tricky spots and which rule/pattern you followed]
### Test plan
- [Exact rspec commands run and outcomes]
Characterization test golden rule
render_viewsresponse.body)assigns(...)-style assertionsRuby method signatures
param = nil, no param: nil)T.nilable(...) and require callers to pass values explicitlyAvoid OpenStruct
OpenStruct in production code or specs for this work; use typed structs, hashes, or verified doubles as appropriate