skills/dailydm/skills/erb-to-squarekit-view-migration

erb-to-squarekit-view-migration

SKILL.md

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:

  1. Which model type triggers it
  2. Which partials it renders
  3. 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_comments displayed as checkbox "Make comments public" (inverted)
  • DB field hide_directory displayed 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, ...View suffix
  • .cursor/rules/graphql/error-handling.mdc — GqlErrors mapping to HTTP codes
  • .cursor/rules/graphql/sorbet-representable.mdcGqlTypes::*Representable mixins
  • .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::Query mixins in mutations/queries
  • Use T::Struct for 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:

  1. Query::Get<Resource> — fetch all edit data as a typed struct
  2. Mutation::Update<Resource> — handle the full save operation
  3. 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 (not GetGroup)
  • 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:

  1. Defines a zod schema matching the GraphQL mutation input
  2. Uses useForm with zodResolver
  3. Populates defaultValues from the query data
  4. 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 + SquareKit FormField, Input, Textarea, Combobox, RadioGroup, Checkbox
  • Layout: Page, Container, Card, Accordion
  • Tables: TanStack useReactTable + SquareKit Paginator
  • 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:

  1. Add keys under en.front_end.<namespace>.* in config/locales/en.yml
  2. Mirror the structure in config/locales/es.yml (locale parity is mandatory)
  3. Configure i18n-js sync in config/i18n_js/config.yml
  4. Register the namespace in app/frontend/src/i18n/i18n.ts
  5. Pass "ns": "<namespace>" via Rails app_context
  6. Run npm run sync-translations after YML changes
  7. 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)

  1. Only reading the top-level ERB: Always trace partials 3 levels deep
  2. Missing model type branching: Check for is_a?, STI, or type columns
  3. Ignoring feature flags in ERB: Count all feature_enabled? and Flipper checks
  4. Forgetting permission-gated sections: Every can?() check maps to a permission flag
  5. Assuming uniform sub-components: Different model types may need different component variants for the "same" section
  6. Boolean inversions: Watch for UI labels that invert DB column semantics
  7. Monolithic PR: Split into flags, backend, frontend from the start
  8. Fat GraphQL resolvers: Always move business logic to domain layer
  9. Missing Packwerk boundaries: Create package.yml for new GQL packages
  10. Hardcoded values by type: Some types force specific values (e.g., Community groups are always public — no visibility radio)
  11. Skipping schema dump / codegen: Always run bin/dump_graphql_schema + npm run codegen after GraphQL changes
  12. Missing GqlTypes::*Representable: All domain structs exposed via GraphQL need the appropriate Representable mixin
  13. Using CRUD names for domain operations: Use imperative, user-centric names per domains/mutation.mdc (e.g., UpdateGroup not SaveGroup)
  14. Exposing ActiveRecord objects: Domain queries/mutations must return T::Struct, never AR models
  15. Raising exceptions for expected failures: Use Result.failure() per domain rules, not exceptions
  16. Skipping i18n: Every user-facing string must use t() backed by Rails YML locale files (en + es), not inline fallbacks
  17. Missing ...View suffix: Screen-aligned GraphQL query types should use ...View suffix per graphql/view-models-and-operations.mdc
  18. Adding to package_todo.yml: Never add Packwerk violations as debt — declare the dependency in package.yml
  19. Incomplete Playwright matrix: Test ALL model-type variants under BOTH the legacy ERB and React UIs, not just legacy
  20. 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

Weekly Installs
3
Repository
dailydm/skills
First Seen
7 days ago
Installed on
opencode3
gemini-cli3
claude-code3
github-copilot3
codex3
amp3