Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ Skills may also include a `references/` directory for supporting markdown files
### UX & Frontend
- **ux-review**: Reviews UI code against Nielsen's 10 heuristics and the Laws of UX in one pass. Owns the procedure, stack-specific checks, severity scale, and issue-log format; does not restate the heuristics themselves.
- **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.
- **turbo-modals**: RoleModel's Turbo Frame modal/panel pattern, installed by `rolemodel_rails`' `rolemodel:turbo:modals` generator. Key rules: `render layout: 'modal'`, 422 on validation errors, redirect on success, footer submit buttons need `form:`, and never use the `layout` class method.

### Rails Backend
- **rails-conventions**: Only the RoleModel Rails rules an agent got wrong without a skill in baseline tests (October 2026): `Successfully <Action> <Model>` flash wording, `:unprocessable_content` for failed saves, Simple Form for every form, `parent_resource.name.classify` in route concerns, plus gotchas for `params.expect` nested attributes (`[[...]]`) and Lexxy's `lexxy:change` event. Standard Rails 8 practice the agent already follows is deliberately left out; re-run a baseline before adding rules.
- **polymorphic-parent-resources**: Serve a child resource that hangs off many different parents (comments, reports, duplications, attachments) from a single controller, instead of one namespaced controller per parent. Pairs a route concern passing `commentable_type: parent_resource.name.classify` with `resource_for` from the `rolemodel_rails` gem (>= 2.4.0). Documents the shallow-nesting trap where member routes inherit the first-drawn parent's `*_type` default. `references/retrofit.md` covers auditing and consolidating existing per-parent controllers.
- **soft-destroyable**: The `SoftDestroyable` concern installed by `rolemodel_rails`' `rolemodel:soft_destroyable` generator. Default scopes are forbidden (the concern raises); use `kept`/`only_deleted`, `soft_destroy!`, `cascade_soft_destroy`, and the `a soft destroyable` shared example.

### Documentation
- **scaffold-docs**: Installs the agent-documentation structure in a project — minimal `AGENTS.md`, a `docs/` tree with `CONVENTIONS.md` and `INDEX.md`, the `surface_conventions.rb` PreToolUse hook, and `wrap-up` symlinked out of this repo. Deliberately writes no documentation: empty indexes are correct on day one, and `wrap-up` fills them in over time. Also trims an existing `AGENTS.md` under 50 lines by routing its content down into the doc layers. Templates live in `templates/*.template.md`; the trimming rules in `references/trimming.md` load only on the branch that needs them.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ What isn't a skill: a project's own conventions (put those in its `AGENTS.md` or
|-------|-------------|
| **[rails-conventions](skills/rails-conventions)** | RoleModel's Rails conventions where they differ from what an agent writes by default: flash wording, `:unprocessable_content`, Simple Form for every form, `parent_resource.name.classify` in route concerns, and gotchas for `params.expect` with nested attributes and Lexxy change events. |
| **[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`. |
| **[turbo-modals](skills/turbo-modals)** | RoleModel's Turbo Frame modal and panel pattern from `rolemodel_rails`: `modal_link_to`, `render layout: 'modal'`, 422 on errors, redirect on success, and never the `layout` class method. |
| **[soft-destroyable](skills/soft-destroyable)** | Soft-delete models with the `SoftDestroyable` concern from `rolemodel_rails` — `kept`/`only_deleted` instead of a default scope, cascading soft destroy and timestamp-matched restore, and the shared example. |
| **[polymorphic-parent-resources](skills/polymorphic-parent-resources)** | Serve a child resource that hangs off many different parents (comments, reports, duplications, attachments) from a single controller, using a route concern plus `resource_for` from `rolemodel_rails`. Includes a retrofit guide for consolidating existing per-parent controllers. |
| **[tdd](skills/tdd)** | Test-driven development for Rails — outside-in with RSpec, Capybara, and FactoryBot. Covers the red-green loop, spec plans, and the Prove-It pattern for bugs. |

Expand Down
46 changes: 46 additions & 0 deletions skills/soft-destroyable/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
name: soft-destroyable
description: Soft-delete ActiveRecord models with the `SoftDestroyable` concern installed by the rolemodel_rails `rolemodel:soft_destroyable` generator, including cascading soft deletes and restores across associations. Use when making a model soft-deletable, deleting or restoring a soft-destroyable record, querying around deleted records, or testing a soft-destroyable model.
metadata:
triggers: "soft delete, soft destroy, soft_destroy, soft_destroyable, SoftDestroyable, deleted_at, kept, only_deleted, restore, cascade_soft_destroy, archive record, a soft destroyable"
---

# Soft Destroyable

`app/models/concerns/soft_destroyable.rb` marks records deleted by setting a `deleted_at` timestamp instead of removing rows. If the concern isn't there, run `bin/rails g rolemodel:soft_destroyable`.

## Making a model soft-destroyable

1. Add a nullable `deleted_at` datetime column (the generator doesn't add one).
2. Include the concern, and cascade to any associations that should go and come back with the parent:

```ruby
class Estimate < ApplicationRecord
include SoftDestroyable

has_many :line_items
cascade_soft_destroy :line_items # each associated model must also include SoftDestroyable
end
```

## Rules

- **Never add a `default_scope`.** The concern raises if you do. Scope explicitly with `Estimate.kept` / `Estimate.only_deleted`, including in associations and policy scopes.
- **Call `soft_destroy!`, not `destroy`.** `destroy` still hard-deletes. `soft_destroy!` raises `ActiveModel::ValidationError` if the update fails. `soft_destroy` returns falsy instead.
- **Restores match on timestamp.** `restore!` clears `deleted_at` and restores cascaded children *soft-destroyed at the same moment*. Children deleted earlier on their own stay deleted. Restore failures only surface at the top level.
- `soft_destroyed?` reports the state. The class-level `soft_destroy` / `restore(timestamp)` act on relations.
- If assigned attributes include `_soft_destroy`, every other attribute is dropped and only `_soft_destroy` is assigned. The model must define a `_soft_destroy=` writer, or that assignment raises `ActiveModel::UnknownAttributeError`.

## Testing

Use the shared example installed at `spec/support/shared_examples/soft_destroyable_behavior.rb`:

```ruby
RSpec.describe Estimate do
it_behaves_like 'a soft destroyable', :estimate,
factory_traits: [:with_line_items],
dependent_destroy_relations: [:line_items]
end
```

The factory (plus traits) must build records that have the cascaded associations populated, or the dependent examples have nothing to check.
78 changes: 78 additions & 0 deletions skills/turbo-modals/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
name: turbo-modals
description: Open forms and content in a modal or side panel using RoleModel's Turbo Frame modal pattern, as installed by the rolemodel_rails `rolemodel:turbo:modals` generator. Use when adding a modal or panel, linking to an action that should open in one, rendering a form inside one, or debugging a modal that won't open, won't close after save, re-animates on validation errors, or renders the wrong layout.
metadata:
triggers: "modal, panel, turbo frame modal, modal_link_to, panel_link_to, link_to_top, layout: 'modal', layout: 'panel', modal_header, modal_footer, panel_header, turbo:frame-missing, unprocessable_content, modal won't close, modal layout"
---

# Turbo Modals

A modal (or panel) is an ordinary controller action rendered with the `modal` (or `panel`) layout into the matching `turbo_frame_tag 'modal'` (or `'panel'`) that lives in the application layout. There is no modal-specific JavaScript to write.

Check the app has the pattern installed before using it: `app/views/layouts/modal.html.slim` and `app/helpers/turbo_frame_link_helper.rb` exist. Panels also need `app/views/layouts/panel.html.slim`. If they're missing, run `bin/rails g rolemodel:turbo:modals` (add `--panels` for panels).

## Opening one

```slim
/ link_to_top breaks out of the frame
= modal_link_to 'New Widget', new_widget_path
= panel_link_to 'Details', widget_path(widget)
= link_to_top 'Leave the modal', widgets_path
```

These are `link_to` with `data: { turbo_frame: 'modal' | 'panel' | '_top' }`.

## The controller rules

1. **Render with the layout explicitly:** `render layout: 'modal'`. Do that on the error path too.
2. **Validation errors respond with `status: :unprocessable_content` (422).** Turbo needs it to re-render the form, and the layout reads it to keep the modal open without re-animating.
3. **Success redirects.** The redirected page has no `modal` frame, so the installed `turbo:frame-missing` handler turns it into a full-page `replace` visit, which closes the modal and refreshes the page underneath.
4. **Never call the `layout` class method** in a controller or any of its ancestors. It overrides the Turbo-Rails layout mechanics the pattern depends on. For an alternate app layout, create a layout-only subclass of `ApplicationController` named after the layout (e.g. `FullscreenController`) and inherit from it.

```ruby
class WidgetsController < ApplicationController
def new
@widget = authorize Widget.new
render layout: 'modal'
end

def create
@widget = authorize Widget.new(widget_params)

if @widget.save
redirect_to @widget, notice: 'Successfully Created Widget'
else
render :new, status: :unprocessable_content, layout: 'modal'
end
end
end
```

## The view

The modal layout gives you `:modal_header` and `:modal_footer` slots (panels have `:panel_header`). The footer renders **outside** the `<form>`, so a submit button there must name the form with `form:`:

```slim
= content_for :modal_header do
h2 Edit Widget

= content_for :modal_footer do
= button_tag 'Save', class: 'btn btn--primary', form: dom_id(@widget, :edit)

= simple_form_for @widget do |f|
= f.input :name
```

For a `new` template the form id is `dom_id(@widget)` (e.g. `new_widget`). Inside the form builder, use `f.submit form: f.id`. The layout already adds Cancel and close buttons.

## Troubleshooting

| Symptom | Cause |
|---|---|
| Modal re-animates or closes on a validation error | The error path isn't returning 422 |
| Save does nothing / the form stays open | The success path renders instead of redirecting |
| Footer button doesn't submit | `form:` is missing or doesn't match the form's id |
| Page opens full-screen instead of in the modal | The link is missing `turbo_frame`, or the action didn't `render layout: 'modal'` |
| Wrong layout inside the frame | A `layout` class method somewhere in the controller's ancestry |

Background reading: [TurboFrame Modals – The Definitive Guide](https://medium.com/@outlawandy/turbo-frame-modals-869801e37591).