ERB-to-React/SquareKit Migration
Systematic process for converting Rails ERB views to React/SquareKit, learned from the groups/edit.html.erb migration (PRs #17422, #17597, #17603, #17657).
Phase 0: Deep ERB Reconnaissance
This is the most critical phase. Mistakes here cascade through everything.
Before writing any code, build a complete map of the ERB page:
Step 1: Trace the Full Partial Tree
Task Progress:
- [ ] Read the main ERB template
- [ ] List ALL files in the view directory (app/views/<resource>/)
- [ ] Read every partial referenced from the main template
- [ ] Read every partial referenced from those partials (go 3 levels deep)
- [ ] Document the complete partial dependency tree
ERB partials hide enormous complexity. The groups/edit.html.erb looked simple (3 conditional branches), but each branch rendered partials that rendered more partials — 20+ files total.
How to trace: Search for these patterns in each ERB file:
render 'partial_name'render partial: 'path/to/partial'render 'resource/component/partial_name'
Files starting with _ in the views directory are partials. Read ALL of them, even ones that seem unrelated — they may be conditionally rendered.
Step 2: Map Type-Based Branching
Many edit pages render different forms based on model subclass:
# Common pattern in ERB
if @model.is_a?(SubTypeA)
render 'subtype_a_form'
elsif @model.is_a?(SubTypeB)
render 'subtype_b_form'
else
render 'default_form'
end
For each branch, document:
- Which model type triggers it
- Which partials it renders
- Which sections are unique vs shared across types
Step 3: Catalog All Conditional Rendering
ERB views gate UI sections on multiple dimensions. Find every instance of:
| Condition Type | Pattern | Example |
|---|---|---|
| Model type | is_a?(SubType) | @group.is_a?(DynamicGroup) |
| Model properties | @model.property? | @group.include_students?, @group.static_group? |
| Permissions | can?(:action, @model) | can?(:create_new_or_update, @group) |
| Feature flags | feature_enabled?(:flag) | @institute.feature_enabled?(:external_group_members) |
| Instance variables | @var_name | @group_manager_role_enabled |
| Params | params[:key] | params[:visibility] |
Produce a matrix: rows = UI sections, columns = conditions that gate them.
Step 4: Identify Field Inversions and Naming Mismatches
ERB views sometimes invert boolean semantics in the UI:
- DB field
private_commentsdisplayed as checkbox "Make comments public" (inverted) - DB field
hide_directorydisplayed as "Hide from directory" (direct)
Document every field where the UI label doesn't match the DB column semantics.
Phase 1: Plan the Component Architecture
Map ERB Partials to React Components
Main ERB template → Root component (router/loader)
Type-specific forms → Form variant components (one per model type)
Shared partials → Shared sub-components
Type-specific partials → Inline in the form variant
Key architectural decisions from the groups migration:
| ERB Pattern | React Pattern |
|---|---|
edit.html.erb with type check | ManageGroup.tsx fetches data, delegates to form variant |
_selection_form.html.erb | StaticGroupForm.tsx |
_dynamic_form.html.erb | DynamicGroupForm.tsx |
_community_form.html.erb | CommunityGroupForm.tsx |
_group_basics.html.erb | Inline in each form (too small for a component) |
_owners.html.erb / _managers.html.erb | OwnersSection.tsx / ManagersSection.tsx (shared) |
_dynamic_conditions.html.erb | conditions/RoleConditionSection.tsx + OptionalConditionBuilder.tsx + ConditionRow.tsx |
_add_members_by_name.html.erb | accordions/MemberSelectionAccordion.tsx + MemberSelectionTable.tsx |
_add_external_members.html.erb | accordions/GuestMembersAccordion.tsx |
_add_members_by_uploading_csv.html.erb | accordions/CsvUploadAccordion.tsx |
_group_banner.html.erb | Inline banner section in CommunityGroupForm |
_modal_group_description.html.erb | dialogs/DescriptionInfoDialog.tsx |
Determine Component Sharing Strategy
Not all types use the same component variant:
- Static/Dynamic groups used inline Combobox for owners/managers
- Community groups used dedicated async-search components (OwnersSection/ManagersSection)
Document which sub-components are shared vs type-specific.
Phase 2: Plan the PR Split Strategy
Never ship a monolithic migration PR. Split into 3 focused PRs:
| PR | Scope | Reviewable Size |
|---|---|---|
| PR 1: Feature Flags | Flipper flag + ERB conditional branching + React mount point | ~100 lines |
| PR 2: Backend | Domain mutations/queries, GraphQL API, Packwerk cleanup, RSpec tests | ~3000-5000 lines |
| PR 3: Frontend | React components, GraphQL documents, Vitest + Playwright tests | ~3000-5000 lines |
PR 1 ships first (safe, flag is off). PR 2 and PR 3 can be developed in parallel.
Phase 3: Backend Implementation
Read these rules first — they govern all domain and GraphQL code:
.cursor/rules/domains/overview.mdc— domain vocabulary philosophy.cursor/rules/domains/root.mdc— Root as single entry point.cursor/rules/domains/mutation.mdc— mutation naming, inputs, Result types.cursor/rules/domains/query.mdc— query naming, struct returns, reader connections.cursor/rules/graphql/overview.mdc— screen-based view models,...Viewsuffix.cursor/rules/graphql/error-handling.mdc— GqlErrors mapping to HTTP codes.cursor/rules/graphql/sorbet-representable.mdc—GqlTypes::*Representablemixins.cursor/rules/graphql/auth-and-context.mdc— auth at top-level resolver.cursor/rules/graphql/pagination-and-bounds.mdc— bounded lists, cursor pagination.cursor/rules/graphql/code-first-and-codegen.mdc— schema dump + codegen steps.cursor/rules/erb/template-viewmodel-migration.mdc— ViewModel pattern for ERB
Domain Layer Pattern
Use the generators to scaffold, then customize:
bundle exec rails generate domain_query Get<Resource> <domain_name>
bundle exec rails generate domain_mutation Update<Resource> <domain_name>
Every data operation goes through a domain Root module (see domains/root.mdc):
# pack_public: true
# typed: strict
module Resource
class Root
extend T::Sig
# Root attributes define the scope shared by ALL operations.
# If a value is only needed by some operations, pass it per-method instead.
sig { params(tenant: Tenant).void }
def initialize(tenant:)
@tenant = tenant
end
## Queries — noun-based names (see domains/query.mdc)
sig {
params(resource_id: Integer, current_user: User)
.returns(Result[Structs::ResourceEditData, Query::GetResource::QueryError])
}
def get_resource(resource_id:, current_user:)
Query::GetResource.new(tenant_id: @tenant.id, resource_id:, current_user:).fetch
end
## Mutations — imperative-tense names (see domains/mutation.mdc)
sig {
params(input: Structs::UpdateResourceInput, current_user: User)
.returns(Result[Structs::ResourceEditData, Mutation::UpdateResource::MutateError])
}
def update_resource(input:, current_user:)
Mutation::UpdateResource.new(tenant_id: @tenant.id, input:, current_user:).mutate
end
end
end
Key rules to follow:
- Include
Domain::Mutation/Domain::Querymixins in mutations/queries - Use
T::Structfor structured inputs (group related params semantically) - Return
Result[SuccessType, ErrorType]— never raise for expected failures - Never expose ActiveRecord objects — convert to structs via
.to_struct/.structs - Use reader connections (
ApplicationRecord.with_reader) in queries - Avoid N+1 queries — use eager loading
Required domain operations for a typical edit page:
Query::Get<Resource>— fetch all edit data as a typed structMutation::Update<Resource>— handle the full save operation- Additional queries for search/selection (e.g.,
UserSearch,MembershipCandidates)
The GetResource Query Must Return Everything
The query struct replaces ALL controller instance variables. Include:
- Basic model attributes
- Associated records (owners, managers, members, etc.)
- Permission flags (
can_edit_settings,can_edit_owners, etc.) — computed server-side - Feature flags (
manager_role_enabled,external_members_enabled, etc.) - Available options for selects/conditions (e.g., condition type options with their values)
- Nested data for complex sub-forms (conditions, external members, etc.)
Output structs must include GqlTypes::ObjectRepresentable:
class Structs::ResourceEditData < T::Struct
include GqlTypes::ObjectRepresentable
def self.gql_type_name = 'ResourceEditData'
def self.gql_description = 'Full edit data for a resource'
const :id, Integer
const :name, String
const :can_edit_settings, T::Boolean
# ...
end
Input structs must include GqlTypes::InputObjectRepresentable.
GraphQL Layer
Name GraphQL types with ...View suffix for screen-aligned queries (per graphql/view-models-and-operations.mdc):
- Query:
GetGroupEditView(notGetGroup) - Types:
GroupEditView,GroupEditViewInput - Mutations may return shallower payloads and trigger refetch of the view
Resolvers must be thin adapters — all IO/logic in the top-level resolver only (see graphql/overview.mdc). Map domain errors to HTTP codes via GqlErrors (see graphql/error-handling.mdc):
class GqlMutations::Resource::UpdateResource < GqlMutations::BaseMutation
def resolve(input:)
result = Resource::Root.new(tenant:).update_resource(input: domain_input, current_user:)
case result
in Success(value) then { success: true, resource: value }
in Failure(Mutation::UpdateResource::AuthorizationError)
raise GqlErrors::Forbidden, 'Not authorized'
in Failure(Mutation::UpdateResource::NotFoundError)
raise GqlErrors::NotFound, 'Resource not found'
in Failure(Mutation::UpdateResource::ValidationError => e)
raise GqlErrors::Validation, e.message
end
end
end
After adding/changing GraphQL types, always run:
bin/dump_graphql_schema # Update schema.graphql artifact
npm run codegen # Regenerate TypeScript types
Auth is enforced at the top-level resolver (see graphql/auth-and-context.mdc). All lists must be bounded with pagination (see graphql/pagination-and-bounds.mdc).
Packwerk Boundary Compliance
If the GraphQL package needs to call domain code, create a package.yml:
# app/graphql/gql_mutations/<resource>/package.yml
enforce_dependencies: true
dependencies:
- .
- app/domains/<resource>
Run bin/packwerk check to verify zero violations. Never add violations to package_todo.yml — always declare the dependency properly in package.yml instead.
When GQL-Layer Logic Is Acceptable
Transport-layer concerns like CSV parsing can live in the GraphQL layer rather than the domain layer. The groups migration placed UploadStudentCsv, UploadStaffCsv, and UploadExternalCsv in gql_mutations/groups/ because CSV parsing is a transport concern (base64 decoding, column mapping, validation) — the domain only cares about the resulting IDs. Use this as a guideline: if the logic is about translating a transport format into domain inputs, it belongs in the GQL layer. If it's business logic, it belongs in the domain.
Phase 4: Frontend Implementation
Root Component Pattern
// ManageGroup.tsx pattern
const ManageResource = () => {
const { data, loading, error } = useGetResourceQuery({ variables: { id } });
if (loading) return <Skeleton />;
if (error || !data) return <ErrorState />;
// Branch on resource type
switch (data.resource.type) {
case 'typeA': return <TypeAForm resource={data.resource} />;
case 'typeB': return <TypeBForm resource={data.resource} />;
default: return <DefaultForm resource={data.resource} />;
}
};
Form Pattern (react-hook-form + zod)
Each form variant:
- Defines a zod schema matching the GraphQL mutation input
- Uses
useFormwithzodResolver - Populates
defaultValuesfrom the query data - Submits via the update mutation
Permission-Gated Sections
Render sections conditionally based on permission flags from the query:
{resource.canEditOwners && (
<Card>
<CardHeader><CardTitle>Owners</CardTitle></CardHeader>
<CardContent>{/* owners UI */}</CardContent>
</Card>
)}
SquareKit Component Usage
- Forms:
react-hook-form+zod+ SquareKitFormField,Input,Textarea,Combobox,RadioGroup,Checkbox - Layout:
Page,Container,Card,Accordion - Tables: TanStack
useReactTable+ SquareKitPaginator - Dialogs: SquareKit
Dialog - All Tailwind classes prefixed with
tw-
i18n — No Hardcoded User-Facing Strings
Follow .cursor/rules/i18n/full-stack-i18n-rails-react.mdc for all user-facing text:
- Add keys under
en.front_end.<namespace>.*inconfig/locales/en.yml - Mirror the structure in
config/locales/es.yml(locale parity is mandatory) - Configure i18n-js sync in
config/i18n_js/config.yml - Register the namespace in
app/frontend/src/i18n/i18n.ts - Pass
"ns": "<namespace>"via Railsapp_context - Run
npm run sync-translationsafter YML changes - Use
t('key')from@/i18n/use-translation— never inline English strings
The groups migration used t('manage_group.key', 'Fallback') with inline fallbacks but did not fully set up the YML locale files or Spanish translations. Future migrations must complete the full i18n pipeline.
Handling Rails Nested Attributes in GraphQL
For CRUD of nested records (e.g., conditions), replicate Rails accepts_nested_attributes_for:
// Create: no id
{ name: 'school', selector: 'ONE_OF', value: ['Elementary'] }
// Update: has id
{ id: '123', name: 'school', selector: 'ONE_OF', value: ['Elementary', 'Middle'] }
// Delete: has id + _destroy
{ id: '456', _destroy: true }
Phase 5: Testing Strategy
Backend Tests (RSpec)
- Domain mutation specs: Test every attribute update, permission checks, not-found, edge cases (minimum owner constraint, etc.)
- Domain query specs: Test struct shape, permission flag computation, type-specific data
- GraphQL integration specs: Test HTTP request/response, error mapping
Frontend Tests (Vitest)
- One test file per component: Mock GraphQL hooks, test rendering, interactions, mutation payloads
- Mock pattern:
vi.mock()for hooks,renderWithProviders()wrapper
E2E Tests (Playwright)
Use a dual-UI Page Object Model so the same tests validate both ERB and React:
// GroupEditPage.ts
get nameInput() {
const legacy = this.page.locator('input[name="group[name]"]');
const react = this.page.getByRole('textbox', { name: /display name/i });
return legacy.or(react);
}
This pattern enables characterization testing during the migration and catches regressions.
Run the full test matrix under BOTH UIs. The groups migration's Playwright suite thoroughly tested static/dynamic/community groups under the legacy ERB, but only tested static groups under the React flag. This left dynamic and community group types without E2E coverage in the React UI. Every model-type variant must be exercised with the feature flag both on and off.
Common Pitfalls (Learned from Groups Migration)
- Only reading the top-level ERB: Always trace partials 3 levels deep
- Missing model type branching: Check for
is_a?, STI, or type columns - Ignoring feature flags in ERB: Count all
feature_enabled?and Flipper checks - Forgetting permission-gated sections: Every
can?()check maps to a permission flag - Assuming uniform sub-components: Different model types may need different component variants for the "same" section
- Boolean inversions: Watch for UI labels that invert DB column semantics
- Monolithic PR: Split into flags, backend, frontend from the start
- Fat GraphQL resolvers: Always move business logic to domain layer
- Missing Packwerk boundaries: Create
package.ymlfor new GQL packages - Hardcoded values by type: Some types force specific values (e.g., Community groups are always public — no visibility radio)
- Skipping schema dump / codegen: Always run
bin/dump_graphql_schema+npm run codegenafter GraphQL changes - Missing
GqlTypes::*Representable: All domain structs exposed via GraphQL need the appropriate Representable mixin - Using CRUD names for domain operations: Use imperative, user-centric names per
domains/mutation.mdc(e.g.,UpdateGroupnotSaveGroup) - Exposing ActiveRecord objects: Domain queries/mutations must return
T::Struct, never AR models - Raising exceptions for expected failures: Use
Result.failure()per domain rules, not exceptions - Skipping i18n: Every user-facing string must use
t()backed by Rails YML locale files (en + es), not inline fallbacks - Missing
...Viewsuffix: Screen-aligned GraphQL query types should use...Viewsuffix pergraphql/view-models-and-operations.mdc - Adding to
package_todo.yml: Never add Packwerk violations as debt — declare the dependency inpackage.yml - Incomplete Playwright matrix: Test ALL model-type variants under BOTH the legacy ERB and React UIs, not just legacy
- Putting transport logic in the domain: CSV parsing, file upload handling, and format translation belong in the GQL layer, not the domain
Checklist
ERB Reconnaissance:
- [ ] Read main template + ALL partials (3 levels deep)
- [ ] Map model type branching (list every subtype form)
- [ ] Catalog ALL conditional rendering (permissions, flags, properties)
- [ ] Document field name inversions
- [ ] Count total partials and sections per type
- [ ] Check for existing ViewModel pattern (see erb/template-viewmodel-migration.mdc)
Architecture:
- [ ] Component tree mapped (root → form variants → sub-components)
- [ ] Shared vs type-specific components identified
- [ ] PR split strategy planned (flags → backend → frontend)
Backend (verify against domain + GraphQL rules):
- [ ] Used generators: `rails generate domain_query` / `domain_mutation`
- [ ] Domain::Mutation / Domain::Query mixins included
- [ ] T::Struct inputs with semantic grouping (not primitives)
- [ ] Result[SuccessType, ErrorType] return types throughout
- [ ] Structs include GqlTypes::ObjectRepresentable / InputObjectRepresentable
- [ ] Domain query returns all edit data as typed struct (replaces @instance_vars)
- [ ] Domain mutation handles all save operations
- [ ] Permission flags computed server-side in the query
- [ ] GraphQL resolvers are thin adapters (no business logic)
- [ ] GqlErrors mapping (Forbidden/NotFound/Validation) — not rescuing in resolvers
- [ ] Auth at top-level resolver only (not per-field)
- [ ] Lists are bounded with pagination
- [ ] Packwerk boundaries clean (package.yml + bin/packwerk check)
- [ ] bin/dump_graphql_schema run after schema changes
- [ ] npm run codegen run after .graphql file changes
- [ ] Existing fat resolvers refactored to thin adapters
Frontend:
- [ ] Root component with type-based routing
- [ ] Form variant per model type
- [ ] react-hook-form + zod for all forms
- [ ] Permission-gated sections
- [ ] SquareKit components throughout (see sqkt/component-guidelines.mdc)
- [ ] All Tailwind classes prefixed with tw-
- [ ] i18n: keys in config/locales/{en,es}.yml (no hardcoded strings)
- [ ] i18n: namespace configured in i18n_js + i18n.ts + app_context
- [ ] npm run sync-translations run after locale changes
Testing:
- [ ] Domain mutation + query specs (real objects, no mocks)
- [ ] GraphQL integration specs (HTTP status + error mapping)
- [ ] Vitest component tests (one per component)
- [ ] Playwright dual-UI page object model
- [ ] Playwright: ALL model-type variants tested under BOTH legacy and React UIs
Additional Resources
- For detailed analysis of the groups migration, see groups-migration-reference.md