diff --git a/AGENTS.md b/AGENTS.md index 61bd9f0..15b64a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ Skills may also include a `references/` directory for supporting markdown files - **usability-heuristics**: Audit UIs against Nielsen's 10 Usability Heuristics. Produces a structured issue log with severity ratings, heuristic mappings, and remediation guidance. Designed to complement `laws-of-ux` with a formal evaluation methodology. - **frontend-patterns**: Frontend patterns for Rails applications using Slim templates, Stimulus, and CSS with Optics utilities. - **stimulus-controllers**: Create and register Stimulus controllers for interactive JavaScript features. -- **turbo-fetch**: Implement dynamic form updates using Turbo Streams and Stimulus (cascading dropdowns, conditional fields, dynamic option lists). +- **dynamic-forms**: Forms that change as they are filled in (dependent dropdowns, conditional fields, dynamic option lists) with the `turbo_form` gem, RoleModel's mechanism for dynamic form interaction. Points at the gem's GitHub README for the API. Existing `turbo_fetch` routes, actions, and `turbo-form`/`turbo-fetch` Stimulus wiring are superseded and converted when touched. - **form-auto-save**: Automatic form submission with debounce for seamless auto-save experiences. - **dynamic-nested-attributes**: Rails nested attributes with dynamic add/remove functionality using Turbo Streams and Simple Form. diff --git a/README.md b/README.md index 31bce9d..250e86e 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,11 @@ Skills follow the [Agent Skills](https://agentskills.io) open standard and work | Skill | Description | |-------|-------------| | **[action-cable](skills/action-cable)** | ActionCable for real-time features using WebSockets, broadcasting, and Turbo Streams over cable. | +| **[dynamic-forms](skills/dynamic-forms)** | Forms that change as they are filled in (dependent dropdowns, conditional fields, dynamic option lists) with the `turbo_form` gem — RoleModel's mechanism for dynamic form interaction. Supersedes hand-wired `turbo_fetch`. | | **[dynamic-nested-attributes](skills/dynamic-nested-attributes)** | Rails nested attributes with dynamic add/remove using Turbo Streams and Simple Form. | | **[form-auto-save](skills/form-auto-save)** | Automatic form submission with debounce for seamless auto-save experiences. | | **[frontend-patterns](skills/frontend-patterns)** | Frontend patterns for Rails apps using Slim templates, Stimulus, and CSS with Optics utilities. | | **[stimulus-controllers](skills/stimulus-controllers)** | Create and register Stimulus controllers for interactive JavaScript features. | -| **[turbo-fetch](skills/turbo-fetch)** | Dynamic form updates using Turbo Streams and Stimulus (cascading dropdowns, conditional fields, dynamic option lists). | ### Rails Backend @@ -226,7 +226,7 @@ Several skills are designed to complement each other: - **BEM + Optics** — BEM provides CSS structure; Optics provides design tokens and components. Use both when writing or reviewing stylesheets. - **Laws of UX + Usability Heuristics + AI UX Enhancements** — Laws of UX provides theoretical principles; Usability Heuristics provides a structured audit methodology; AI UX Enhancements adds automatable review rules. Use together for comprehensive UI reviews. -- **Frontend Patterns + Stimulus + Turbo Fetch + Action Cable** — These cover the full Rails frontend stack using Hotwire and real-time features. +- **Frontend Patterns + Stimulus + Dynamic Forms + Action Cable** — These cover the full Rails frontend stack using Hotwire and real-time features. - **Routing Patterns + Controller Patterns + Polymorphic Parent Resources** — Routing Patterns covers the route concern that passes the parent type; Controller Patterns covers the controller conventions; Polymorphic Parent Resources joins the two for child resources that hang off many parents. Reach for it at the second parent, when a nested controller would otherwise be copied. - **Sentry Top Issue + Sentry Issue Fixer** — Top Issue selects the highest-priority Sentry issue; Issue Fixer runs the full diagnosis-and-fix workflow. Run together or invoke the fixer directly with a known issue. - **Explain + Trace** — Explain orients you to a feature area or concept; Trace follows a specific code path through the stack. Use Explain first to build context, then Trace to dig into a specific flow. diff --git a/skills/dynamic-forms/SKILL.md b/skills/dynamic-forms/SKILL.md new file mode 100644 index 0000000..c53a6a9 --- /dev/null +++ b/skills/dynamic-forms/SKILL.md @@ -0,0 +1,36 @@ +--- +name: dynamic-forms +description: Build forms that change as they are filled in — cascading or dependent dropdowns, conditional fields and sections, option lists that depend on another field, STI type switches — with the turbo_form gem. Use when a form must update without a full page reload, when adding or changing a dynamic form, or when touching existing `turbo_fetch` routes, actions, or `turbo-form`/`turbo-fetch` Stimulus wiring. +metadata: + triggers: "dynamic form, dependent dropdown, cascading dropdown, conditional field, conditional section, dynamic options, update form on change, turbo_form, dynamic: true, dynamic_trigger, dynamic_action, expect_dynamic_form_request, turbo_fetch, turbo-fetch, turbo-form controller" +--- + +# Dynamic Forms + +The [turbo_form](https://github.com/OutlawAndy/turbo_form) gem is RoleModel's mechanism for dynamic form interaction. Every new dynamic form uses it. Do not write a `turbo_fetch` route, controller action, `.turbo_stream` view, or Stimulus controller for a form that turbo_form can drive. + +The gem's [README](https://github.com/OutlawAndy/turbo_form#readme) is the API documentation. Read it before writing or changing a dynamic form. This skill does not restate it. + +## Setup + +Check the `Gemfile` for `turbo_form`. If it is missing, add it and run `bin/rails generate turbo_form:install`, then draw the `:turbo_form` route concern on the resource: + +```ruby +resources :materials, concerns: :turbo_form +``` + +The engine defines the concern and the controller behavior. Do not define a `:turbo_form` or `:turbo_fetch` concern in `config/routes.rb`. + +## Shape + +```slim += simple_form_for @material, dynamic: true do |f| + = f.input :type, input_html: { dynamic_trigger: true } + = f.input :substance, collection: @material.substances +``` + +The trigger re-renders the page through its own `new` or `edit` action with the submitted values assigned, so `@material.substances` answers for the type just picked. The options, conventions, `dynamic_action:` for custom Turbo Stream responses, and `expect_dynamic_form_request` for system tests are all in the README. + +## Existing `turbo_fetch` code + +Older apps wire dynamic forms by hand: a `turbo_fetch` route concern, a `turbo_fetch` controller action, a `turbo_fetch.turbo_stream` view, a Stimulus controller registered as `turbo-form` or `turbo-fetch` (installed by `rolemodel_rails`, and unrelated to the turbo_form gem despite the name), and `expect_turbo_form_request` or `expect_turbo_fetch_request` in specs. That pattern is superseded. Do not extend it. When a change touches one of these forms, convert it to turbo_form and delete the route, action, view, and spec helper usage it no longer needs. diff --git a/skills/form-auto-save/SKILL.md b/skills/form-auto-save/SKILL.md index 03fb35f..8e3811d 100644 --- a/skills/form-auto-save/SKILL.md +++ b/skills/form-auto-save/SKILL.md @@ -26,6 +26,7 @@ The pattern uses a Stimulus controller (`form-auto-save`) that handles the auto- - Listens to both `change` and `lexxy:change` events (for custom components) - Uses passive event listeners for better performance - Provides `cancel()` and `submit()` methods for programmatic control +- Counts successful saves in `countValue`, which system specs wait on **Controller Code Pattern:** ```javascript @@ -33,10 +34,12 @@ import { Controller } from '@hotwired/stimulus' export default class extends Controller { static DEBOUNCE_TIME = 8000 + static values = { count: Number } connect() { this.element.addEventListener('change', this.#debounceSubmit.bind(this), { passive: true }) this.element.addEventListener('lexxy:change', this.#debounceSubmit.bind(this), { passive: true }) + this.element.addEventListener('turbo:submit-end', ({ detail }) => { if (detail.success) this.countValue++ }) } cancel() { @@ -96,58 +99,22 @@ Attach the controller to the form element using Stimulus data attributes. ## Testing -For testing auto-save functionality, use the `turbo-fetch` controller alongside `form-auto-save` to track request completion without relying on sleep timers. +Wait on the controller's `countValue`, not on sleep timers. It goes up when Turbo reports a successful submission, so it covers the debounce and the round trip. `turbo_permanent: true` keeps the form, and its count, when the response redirects. -### Turbo Fetch Controller -Add this controller to your JavaScript controllers: - -**File:** `app/javascript/controllers/turbo_fetch_controller.js` -```javascript -import { Controller } from '@hotwired/stimulus' -import { patch } from '@rails/request.js' - -export default class extends Controller { - static values = { - url: String, - count: Number, - isRunning: { type: Boolean, default: false } - } - - async perform({ params: { url: urlParam, query: queryParams } }) { - this.isRunningValue = true - const body = new FormData(this.element) - - if (queryParams) Object.keys(queryParams).forEach(key => body.append(key, queryParams[key])) - - const response = await patch(urlParam || this.urlValue, { body, responseKind: 'turbo-stream' }) - this.isRunningValue = false - if (response.ok) this.countValue += 1 - } -} -``` - -### Turbo Fetch Helper -Add this helper to your RSpec support files: - -**File:** `spec/support/helpers/turbo_fetch_helper.rb` +### Auto Save Helper +**File:** `spec/support/helpers/form_auto_save_helper.rb` ```ruby -module TurboFetchHelper - def expect_turbo_fetch_request - count_value = find("[data-controller='turbo-fetch']")['data-turbo-fetch-count-value'] || 0 +module FormAutoSaveHelper + def expect_auto_save + form = find("[data-controller~='form-auto-save']") + count = form['data-form-auto-save-count-value'].to_i yield - expect(page).to have_selector("[data-turbo-fetch-count-value='#{count_value.to_i + 1}']") + expect(page).to have_css("[data-form-auto-save-count-value='#{count + 1}']", wait: 10) end end ``` -### View Integration for Testing -Add the `turbo-fetch` controller alongside `form-auto-save`: - -```slim -= simple_form_for resource, html: { data: { controller: 'form-auto-save turbo-fetch', turbo_permanent: true } } do |f| - = f.input :field_name - = f.rich_text_area :content -``` +Include it for system specs in `spec/support/helpers.rb` with `c.include FormAutoSaveHelper, type: :system`. The `wait:` must be longer than `DEBOUNCE_TIME`. ### System Spec Example ```ruby @@ -158,7 +125,7 @@ RSpec.describe 'Form Auto Save', :js do resource = create(:resource) visit edit_resource_path(resource) - expect_turbo_fetch_request do + expect_auto_save do fill_in 'Field name', with: 'Updated value' end @@ -169,7 +136,7 @@ RSpec.describe 'Form Auto Save', :js do resource = create(:resource) visit edit_resource_path(resource) - expect_turbo_fetch_request do + expect_auto_save do fill_in 'Field name', with: 'First' fill_in 'Field name', with: 'Second' fill_in 'Field name', with: 'Final' @@ -202,7 +169,7 @@ end - Consider adding "unsaved changes" warning ## Related Patterns -- **Turbo Streams:** For more complex form updates and partial page replacements +- **Dynamic Forms:** Fields that change as the form is filled in belong to the `dynamic-forms` skill (the turbo_form gem) - **Stimulus Values:** If you need per-instance debounce times - **Form Validation:** Consider inline validation with auto-save diff --git a/skills/rails-audit/references/rails_antipatterns.md b/skills/rails-audit/references/rails_antipatterns.md index c222197..646165c 100644 --- a/skills/rails-audit/references/rails_antipatterns.md +++ b/skills/rails-audit/references/rails_antipatterns.md @@ -73,7 +73,7 @@ class User::ActivationsController < ApplicationController end ``` -**Exceptions**: Actions named `turbo_fetch` are an intentional convention for Turbo Stream responses and should not be flagged as REST violations. +**Exceptions**: Don't flag actions named `turbo_fetch` as REST violations. They are the superseded hand-wired dynamic form pattern: report them as candidates for conversion to the turbo_form gem (see the `dynamic-forms` skill). **Audit Check**: Count public actions per controller. Flag controllers with > 8 actions. Check `config/routes.rb` for excessive `member`/`collection` blocks. diff --git a/skills/routing-patterns/SKILL.md b/skills/routing-patterns/SKILL.md index 145c68d..27df07c 100644 --- a/skills/routing-patterns/SKILL.md +++ b/skills/routing-patterns/SKILL.md @@ -40,12 +40,7 @@ concern :duplicatable do end ``` -**Example: Dynamic Form Updates (Turbo/AJAX)** -```ruby -concern :turbo_fetch do - patch :turbo_fetch, on: :collection -end -``` +**Dynamic forms** use the `:turbo_form` concern, which the `turbo_form` gem's engine defines. Apply it; never define it. See the `dynamic-forms` skill. **Key Points:** - Concerns extract common nested resource patterns into reusable modules @@ -61,8 +56,8 @@ end Apply concerns using the `concerns:` option with an array of symbols: ```ruby -resources :products, concerns: %i[duplicatable turbo_fetch] -resources :articles, concerns: %i[commentable duplicatable turbo_fetch] +resources :products, concerns: %i[duplicatable turbo_form] +resources :articles, concerns: %i[commentable duplicatable turbo_form] ``` **Benefits:** @@ -123,7 +118,6 @@ Always be explicit about which actions a resource provides. This improves securi ```ruby # Only specific actions -resources :webhooks, only: [], concerns: %i[turbo_fetch] # No standard REST actions, only custom resources :duplications, only: %i[create] # Only create action needed resources :previews, only: %i[show update] # Only show and update diff --git a/skills/stimulus-controllers/SKILL.md b/skills/stimulus-controllers/SKILL.md index 309eec7..9edcb88 100644 --- a/skills/stimulus-controllers/SKILL.md +++ b/skills/stimulus-controllers/SKILL.md @@ -204,5 +204,5 @@ end ## Related Skills - [frontend-patterns](../frontend-patterns/SKILL.md) - HTML and CSS patterns -- [turbo-fetch](../turbo-fetch/SKILL.md) - Dynamic form updates +- [dynamic-forms](../dynamic-forms/SKILL.md) - Dynamic form updates, no Stimulus controller needed - [testing-patterns](../testing-patterns/SKILL.md) - Testing JavaScript features diff --git a/skills/turbo-fetch/SKILL.md b/skills/turbo-fetch/SKILL.md deleted file mode 100644 index d9a1a26..0000000 --- a/skills/turbo-fetch/SKILL.md +++ /dev/null @@ -1,210 +0,0 @@ ---- -name: turbo-fetch -description: Implement dynamic form updates using Turbo Streams and Stimulus. Use when forms need to update fields based on user selections without full page reloads, such as cascading dropdowns, conditional fields, or dynamic option lists. ---- - -# Turbo Fetch Skill - -This skill documents the turbo fetch pattern for dynamically updating form fields based on user input using Turbo Streams and Stimulus. - -## When to Use - -Use turbo fetch when you need to: -- Update form options based on another field's selection -- Show/hide conditional fields dynamically -- Refresh parts of a form without reloading the entire page -- Implement cascading dropdowns or dependent fields - -## Pattern Overview - -The turbo fetch pattern consists of four components: - -1. **Routes** - A routing concern that adds the turbo_fetch endpoint -2. **Controller Action** - A backend action that prepares data and renders turbo streams -3. **Stimulus Controller** - Frontend controller that triggers PATCH requests -4. **Turbo Stream View** - Template that updates specific DOM elements - -## Implementation Steps - -### 1. Add Routing Concern (if not already present) - -In `config/routes.rb`, ensure the turbo_fetch concern exists: - -```ruby -concern :turbo_fetch do - patch :turbo_fetch, on: :collection -end -``` - -Then apply it to your resource: - -```ruby -resources :materials, concerns: %i[turbo_fetch] -``` - -This creates a route: `PATCH /materials/turbo_fetch` - -### 2. Implement Controller Action - -Add a `turbo_fetch` action to your controller: - -```ruby -class MaterialsController < ApplicationController - def turbo_fetch - @material = authorize Material.new(material_params) - # The view will handle the turbo stream responses - end - - private - - def material_params - params.require(:material).permit(:type, :substance, ...) - end -end -``` - -**Key points:** -- Creates a new instance with submitted params (doesn't save to database) -- Runs authorization if needed -- The instance will be used in the turbo stream view to determine updated options - -### 3. Create Turbo Stream View - -Create `app/views/[resource]/turbo_fetch.turbo_stream.slim`: - -```slim -= simple_form_for @material do |f| - = turbo_stream.update 'substance-field' do - = f.input :substance, collection: @material.substances - = turbo_stream.replace 'material_details', partial: dimension_fields_partial_path, locals: { f: } -``` - -**Available turbo stream actions:** -- `turbo_stream.update` - Replace the content inside an element -- `turbo_stream.replace` - Replace the entire element -- `turbo_stream.append` - Add content at the end -- `turbo_stream.prepend` - Add content at the beginning -- `turbo_stream.remove` - Remove an element - -### 4. Setup Form with Stimulus Controller - -Add the Stimulus controller to your form: - -```slim -= simple_form_for resource, data: { controller: 'turbo-fetch', turbo_fetch_url_value: turbo_fetch_materials_url } do |f| - .form-row - = f.input :type, input_html: { data: { action: "turbo-fetch#perform" } } - - #substance-field.flexible - = f.input :substance, collection: f.object.substances - - #material_details - = render dimension_fields_partial_path, f: f -``` - -**Key attributes:** -- `data-controller="turbo-fetch"` - Activates the Stimulus controller -- `data-turbo-fetch-url-value` - The URL to PATCH (defaults to form action + /turbo_fetch) -- `data-action="turbo-fetch#perform"` - Triggers the fetch on field change - -### 5. Verify Stimulus Controller Exists - -The `turbo_fetch_controller.js` should exist at `app/javascript/controllers/turbo_fetch_controller.js`: - -```javascript -import { Controller } from '@hotwired/stimulus' -import { patch } from '@rails/request.js' - -export default class extends Controller { - static values = { url: String, count: Number } - - async perform({ params: { url: urlParam, query: queryParams } }) { - const body = new FormData(this.element) - - if (queryParams) Object.keys(queryParams).forEach(key => body.append(key, queryParams[key])) - - const response = await patch(urlParam || this.urlValue, { body, responseKind: 'turbo-stream' }) - if (response.ok) this.countValue += 1 - } -} -``` - -## Examples from Codebase - -### Materials Example - -**Route:** -```ruby -resources :materials, concerns: %i[duplication turbo_fetch] -``` - -**Controller:** -```ruby -def turbo_fetch - @material = authorize Material.new(material_params) -end -``` - -**View (`turbo_fetch.turbo_stream.slim`):** -```slim -= simple_form_for @material do |f| - = turbo_stream.update 'substance-field' do - = f.input :substance, collection: @material.substances, as: :tom_select, allow_create: true - = turbo_stream.replace 'material_details', partial: dimension_fields_partial_path, locals: { f: } -``` - -**Form:** -```slim -= simple_form_for resource, data: { controller: 'turbo-fetch', turbo_fetch_url_value: turbo_fetch_materials_url } do |f| - = f.input :type, input_html: { data: { action: "turbo-fetch#perform" } } - - #substance-field.flexible - = f.input :substance, collection: f.object.substances - - #material_details - = render dimension_fields_partial_path, f: f -``` - -## Common Patterns - -### Pattern 1: Dependent Dropdown -When selecting a type, update available options in another field: -- Trigger field has `data-action="turbo-fetch#perform"` -- Target field has unique ID (e.g., `#substance-field`) -- Turbo stream updates the target with new collection - -### Pattern 2: Conditional Field Sections -Show/hide entire form sections based on selection: -- Use `turbo_stream.replace` to swap out entire sections -- Render different partials based on the selected value - -### Pattern 3: Member vs Collection Routes -Most turbo_fetch routes are on `:collection`, but for nested resources with IDs: - -```ruby -resources :custom_parts, concerns: %i[turbo_fetch] do - patch :turbo_fetch, on: :member # For child items with IDs -end -``` - -## Tips - -1. **Target IDs**: Ensure target elements have unique, stable IDs -2. **Form Context**: The turbo stream view wraps form builder in `simple_form_for` to maintain form context -3. **Authorization**: Apply same authorization as create/update actions -4. **Don't Save**: The turbo_fetch action creates instances but never saves them -5. **Multiple Updates**: You can include multiple turbo_stream updates in one response - -## Troubleshooting - -**Updates not appearing:** -- Check that target element ID matches the turbo_stream selector -- Verify the Stimulus action is firing (check browser console) -- Ensure turbo_fetch route exists (run `rails routes | grep turbo_fetch`) - -**Wrong data in fields:** -- Verify params are being permitted in `material_params` (or equivalent) -- Check that the model's computed properties return correct values - -**Authorization errors:** -- Ensure `turbo_fetch` action runs same authorization as `new`/`create`