nitro_kit 0.9.0 → 2.0.0.alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.agents/plugins/marketplace.json +20 -0
- data/CHANGELOG.md +172 -0
- data/LICENSE +28 -0
- data/README.md +22 -15
- data/STYLE_GUIDE.md +425 -0
- data/app/assets/stylesheets/nitro_kit-tailwind-v4.css +38 -0
- data/app/assets/stylesheets/nitro_kit.css +7239 -0
- data/app/components/nitro_kit/accordion.rb +125 -65
- data/app/components/nitro_kit/alert.rb +80 -43
- data/app/components/nitro_kit/app_navigation.rb +307 -0
- data/app/components/nitro_kit/app_shell.rb +227 -0
- data/app/components/nitro_kit/appearance_bootstrap.rb +146 -0
- data/app/components/nitro_kit/appearance_picker.rb +156 -0
- data/app/components/nitro_kit/auth_shell.rb +34 -0
- data/app/components/nitro_kit/avatar.rb +85 -29
- data/app/components/nitro_kit/avatar_stack.rb +155 -9
- data/app/components/nitro_kit/badge.rb +43 -81
- data/app/components/nitro_kit/button.rb +180 -103
- data/app/components/nitro_kit/button_group.rb +63 -11
- data/app/components/nitro_kit/button_to.rb +69 -0
- data/app/components/nitro_kit/card.rb +42 -45
- data/app/components/nitro_kit/checkbox.rb +115 -62
- data/app/components/nitro_kit/checkbox_group.rb +131 -22
- data/app/components/nitro_kit/choice.rb +55 -0
- data/app/components/nitro_kit/combobox.rb +292 -105
- data/app/components/nitro_kit/command_palette.rb +412 -0
- data/app/components/nitro_kit/component.rb +322 -46
- data/app/components/nitro_kit/container.rb +32 -0
- data/app/components/nitro_kit/control_group.rb +45 -0
- data/app/components/nitro_kit/danger_zone.rb +78 -0
- data/app/components/nitro_kit/data_section.rb +106 -0
- data/app/components/nitro_kit/details_table.rb +229 -0
- data/app/components/nitro_kit/dialog.rb +257 -83
- data/app/components/nitro_kit/dropdown.rb +335 -122
- data/app/components/nitro_kit/dropzone.rb +326 -0
- data/app/components/nitro_kit/empty_state.rb +99 -0
- data/app/components/nitro_kit/field.rb +461 -236
- data/app/components/nitro_kit/field_group.rb +15 -8
- data/app/components/nitro_kit/fieldset.rb +37 -39
- data/app/components/nitro_kit/flex.rb +49 -0
- data/app/components/nitro_kit/form_builder.rb +313 -72
- data/app/components/nitro_kit/form_section.rb +79 -0
- data/app/components/nitro_kit/grid.rb +34 -0
- data/app/components/nitro_kit/icon.rb +54 -25
- data/app/components/nitro_kit/input.rb +97 -10
- data/app/components/nitro_kit/label.rb +22 -6
- data/app/components/nitro_kit/layout_options.rb +7 -0
- data/app/components/nitro_kit/page_header.rb +76 -0
- data/app/components/nitro_kit/pagination.rb +355 -69
- data/app/components/nitro_kit/pagination_bar.rb +90 -0
- data/app/components/nitro_kit/progressive_image.rb +181 -0
- data/app/components/nitro_kit/radio_button.rb +77 -55
- data/app/components/nitro_kit/radio_button_group.rb +107 -36
- data/app/components/nitro_kit/responsive_value.rb +94 -0
- data/app/components/nitro_kit/rich_text_area.rb +31 -0
- data/app/components/nitro_kit/select.rb +129 -64
- data/app/components/nitro_kit/settings_layout.rb +152 -0
- data/app/components/nitro_kit/sheet.rb +202 -0
- data/app/components/nitro_kit/stat_grid.rb +75 -0
- data/app/components/nitro_kit/switch.rb +108 -51
- data/app/components/nitro_kit/table.rb +237 -45
- data/app/components/nitro_kit/tabs.rb +168 -63
- data/app/components/nitro_kit/textarea.rb +81 -10
- data/app/components/nitro_kit/toast.rb +211 -80
- data/app/components/nitro_kit/toolbar.rb +75 -0
- data/app/components/nitro_kit/tooltip.rb +235 -34
- data/app/components/nitro_kit/typeset.rb +28 -0
- data/app/javascript/controllers/nk/app_shell_controller.js +224 -0
- data/app/javascript/controllers/nk/appearance_controller.js +73 -0
- data/app/javascript/controllers/nk/avatar_controller.js +15 -0
- data/app/javascript/controllers/nk/button_controller.js +56 -0
- data/app/javascript/controllers/nk/checkable_controller.js +41 -0
- data/app/javascript/controllers/nk/combobox_controller.js +314 -82
- data/app/javascript/controllers/nk/command_palette_controller.js +249 -0
- data/app/javascript/controllers/nk/dialog_controller.js +14 -10
- data/app/javascript/controllers/nk/dropdown_controller.js +106 -58
- data/app/javascript/controllers/nk/dropzone/direct_upload.js +61 -0
- data/app/javascript/controllers/nk/dropzone/file_rules.js +77 -0
- data/app/javascript/controllers/nk/dropzone/form_submit_lock.js +30 -0
- data/app/javascript/controllers/nk/dropzone_controller.js +482 -0
- data/app/javascript/controllers/nk/overlay_position.js +37 -0
- data/app/javascript/controllers/nk/progressive_image_controller.js +116 -0
- data/app/javascript/controllers/nk/tabs_controller.js +135 -23
- data/app/javascript/controllers/nk/toast_controller.js +112 -45
- data/app/javascript/controllers/nk/tooltip_controller.js +12 -47
- data/config/importmap.rb +2 -0
- data/config/locales/en.yml +98 -0
- data/docs/agent_guide.md +139 -0
- data/docs/agent_native_spec.md +422 -0
- data/docs/component_contracts.md +263 -0
- data/docs/customization.md +595 -0
- data/docs/hotwire.md +94 -0
- data/docs/initialization_prompt.md +36 -0
- data/docs/migration_1_to_2.md +318 -0
- data/docs/new_app_strategy.md +22 -0
- data/docs/patterns/application_foundation.md +136 -0
- data/docs/patterns/crud_resource.md +144 -0
- data/docs/patterns/destructive_action.md +105 -0
- data/docs/patterns/flash_and_toast.md +57 -0
- data/docs/patterns/inline_edit.md +59 -0
- data/docs/patterns/queryable_collection.md +187 -0
- data/docs/patterns/resource_form.md +126 -0
- data/docs/rails_conventions.md +95 -0
- data/docs/rails_integration.md +514 -0
- data/lib/generators/nitro_kit/install_generator.rb +48 -0
- data/lib/generators/nitro_kit/upgrade_smoke_tests_generator.rb +67 -0
- data/lib/nitro_kit/engine.rb +20 -0
- data/lib/nitro_kit/installation.rb +671 -0
- data/lib/nitro_kit/migration_inventory.rb +345 -0
- data/lib/nitro_kit/upgrade_smoke_test.rb +403 -0
- data/lib/nitro_kit/version.rb +1 -1
- data/lib/nitro_kit.rb +5 -42
- data/lib/rails/commands/nitro_kit/nitro_kit_command.rb +42 -0
- data/lib/tasks/nitro_kit_tasks.rake +79 -4
- data/plugins/nitro-kit/.codex-plugin/plugin.json +28 -0
- data/plugins/nitro-kit/skills/nitro-kit-hotwire/SKILL.md +67 -0
- data/plugins/nitro-kit/skills/nitro-kit-hotwire/agents/openai.yaml +4 -0
- data/plugins/nitro-kit/skills/nitro-kit-rails/SKILL.md +60 -0
- data/plugins/nitro-kit/skills/nitro-kit-rails/agents/openai.yaml +4 -0
- data/plugins/nitro-kit/skills/nitro-kit-ui/SKILL.md +62 -0
- data/plugins/nitro-kit/skills/nitro-kit-ui/agents/openai.yaml +4 -0
- data/src/stylesheets/nitro_kit/components/accordion.css +119 -0
- data/src/stylesheets/nitro_kit/components/alert.css +82 -0
- data/src/stylesheets/nitro_kit/components/app_navigation.css +156 -0
- data/src/stylesheets/nitro_kit/components/app_shell.css +605 -0
- data/src/stylesheets/nitro_kit/components/appearance_picker.css +117 -0
- data/src/stylesheets/nitro_kit/components/auth_shell.css +8 -0
- data/src/stylesheets/nitro_kit/components/avatar.css +81 -0
- data/src/stylesheets/nitro_kit/components/avatar_stack.css +80 -0
- data/src/stylesheets/nitro_kit/components/badge.css +76 -0
- data/src/stylesheets/nitro_kit/components/button.css +283 -0
- data/src/stylesheets/nitro_kit/components/button_group.css +53 -0
- data/src/stylesheets/nitro_kit/components/button_to.css +6 -0
- data/src/stylesheets/nitro_kit/components/card.css +70 -0
- data/src/stylesheets/nitro_kit/components/checkbox.css +160 -0
- data/src/stylesheets/nitro_kit/components/checkbox_group.css +61 -0
- data/src/stylesheets/nitro_kit/components/combobox.css +148 -0
- data/src/stylesheets/nitro_kit/components/command_palette.css +255 -0
- data/src/stylesheets/nitro_kit/components/container.css +25 -0
- data/src/stylesheets/nitro_kit/components/control_group.css +149 -0
- data/src/stylesheets/nitro_kit/components/danger_zone.css +54 -0
- data/src/stylesheets/nitro_kit/components/data_section.css +40 -0
- data/src/stylesheets/nitro_kit/components/details_table.css +53 -0
- data/src/stylesheets/nitro_kit/components/dialog.css +80 -0
- data/src/stylesheets/nitro_kit/components/dropdown.css +152 -0
- data/src/stylesheets/nitro_kit/components/dropzone.css +198 -0
- data/src/stylesheets/nitro_kit/components/empty_state.css +53 -0
- data/src/stylesheets/nitro_kit/components/field.css +79 -0
- data/src/stylesheets/nitro_kit/components/field_group.css +6 -0
- data/src/stylesheets/nitro_kit/components/fieldset.css +28 -0
- data/src/stylesheets/nitro_kit/components/flex.css +455 -0
- data/src/stylesheets/nitro_kit/components/form_section.css +71 -0
- data/src/stylesheets/nitro_kit/components/grid.css +308 -0
- data/src/stylesheets/nitro_kit/components/icon.css +26 -0
- data/src/stylesheets/nitro_kit/components/input.css +80 -0
- data/src/stylesheets/nitro_kit/components/label.css +9 -0
- data/src/stylesheets/nitro_kit/components/layout.css +440 -0
- data/src/stylesheets/nitro_kit/components/page_header.css +61 -0
- data/src/stylesheets/nitro_kit/components/pagination.css +119 -0
- data/src/stylesheets/nitro_kit/components/pagination_bar.css +41 -0
- data/src/stylesheets/nitro_kit/components/palette.css +113 -0
- data/src/stylesheets/nitro_kit/components/progressive_image.css +139 -0
- data/src/stylesheets/nitro_kit/components/radio_button.css +127 -0
- data/src/stylesheets/nitro_kit/components/radio_button_group.css +143 -0
- data/src/stylesheets/nitro_kit/components/rich_text_area.css +21 -0
- data/src/stylesheets/nitro_kit/components/select.css +70 -0
- data/src/stylesheets/nitro_kit/components/settings_layout.css +100 -0
- data/src/stylesheets/nitro_kit/components/sheet.css +100 -0
- data/src/stylesheets/nitro_kit/components/stat_grid.css +51 -0
- data/src/stylesheets/nitro_kit/components/switch.css +152 -0
- data/src/stylesheets/nitro_kit/components/table.css +116 -0
- data/src/stylesheets/nitro_kit/components/tabs.css +138 -0
- data/src/stylesheets/nitro_kit/components/textarea.css +39 -0
- data/src/stylesheets/nitro_kit/components/toast.css +131 -0
- data/src/stylesheets/nitro_kit/components/toolbar.css +78 -0
- data/src/stylesheets/nitro_kit/components/tooltip.css +124 -0
- data/src/stylesheets/nitro_kit/components/typeset.css +191 -0
- data/src/stylesheets/nitro_kit/layers.css +7 -0
- data/src/stylesheets/nitro_kit/reset.css +192 -0
- data/src/stylesheets/nitro_kit/tokens.css +198 -0
- metadata +182 -51
- data/Rakefile +0 -8
- data/app/assets/tailwind/application.css +0 -49
- data/app/components/nitro_kit/datepicker.rb +0 -9
- data/app/helpers/nitro_kit/accordion_helper.rb +0 -9
- data/app/helpers/nitro_kit/alert_helper.rb +0 -11
- data/app/helpers/nitro_kit/avatar_helper.rb +0 -13
- data/app/helpers/nitro_kit/badge_helper.rb +0 -13
- data/app/helpers/nitro_kit/button_group_helper.rb +0 -9
- data/app/helpers/nitro_kit/button_helper.rb +0 -49
- data/app/helpers/nitro_kit/card_helper.rb +0 -9
- data/app/helpers/nitro_kit/checkbox_helper.rb +0 -50
- data/app/helpers/nitro_kit/combobox_helper.rb +0 -9
- data/app/helpers/nitro_kit/datepicker_helper.rb +0 -9
- data/app/helpers/nitro_kit/dialog_helper.rb +0 -9
- data/app/helpers/nitro_kit/dropdown_helper.rb +0 -9
- data/app/helpers/nitro_kit/field_group_helper.rb +0 -9
- data/app/helpers/nitro_kit/field_helper.rb +0 -9
- data/app/helpers/nitro_kit/fieldset_helper.rb +0 -9
- data/app/helpers/nitro_kit/form_helper.rb +0 -13
- data/app/helpers/nitro_kit/icon_helper.rb +0 -9
- data/app/helpers/nitro_kit/input_helper.rb +0 -37
- data/app/helpers/nitro_kit/label_helper.rb +0 -19
- data/app/helpers/nitro_kit/pagination_helper.rb +0 -46
- data/app/helpers/nitro_kit/radio_button_helper.rb +0 -23
- data/app/helpers/nitro_kit/select_helper.rb +0 -24
- data/app/helpers/nitro_kit/switch_helper.rb +0 -9
- data/app/helpers/nitro_kit/table_helper.rb +0 -9
- data/app/helpers/nitro_kit/tabs_helper.rb +0 -9
- data/app/helpers/nitro_kit/textarea_helper.rb +0 -9
- data/app/helpers/nitro_kit/toast_helper.rb +0 -25
- data/app/helpers/nitro_kit/tooltip_helper.rb +0 -9
- data/app/javascript/controllers/nk/accordion_controller.js +0 -18
- data/app/javascript/controllers/nk/datepicker_controller.js +0 -5
- data/app/javascript/controllers/nk/switch_controller.js +0 -32
- data/lib/generators/nitro_kit/component_generator.rb +0 -84
- data/lib/nitro_kit/schema_builder.rb +0 -129
- data/lib/nitro_kit/variants.rb +0 -21
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Destructive action
|
|
2
|
+
|
|
3
|
+
Use a native Nitro Dialog when the user needs to review impact or type confirmation. The dialog only owns accessible disclosure and focus behavior; a real Rails form owns the destructive request.
|
|
4
|
+
|
|
5
|
+
## Summary
|
|
6
|
+
|
|
7
|
+
- Use a native Nitro `Dialog` only when the user must review impact or type a
|
|
8
|
+
confirmation; a real Rails form owns the destructive request.
|
|
9
|
+
- Place the reviewed deletion on the resource's edit page, not on the
|
|
10
|
+
operational show page.
|
|
11
|
+
- The delete form targets `_top` so a successful redirect navigates the page
|
|
12
|
+
instead of resolving inside the surrounding frame.
|
|
13
|
+
- Authorize and load the record on the server. The confirmation UI is not a
|
|
14
|
+
security boundary.
|
|
15
|
+
- When the consequence fits in one sentence, use Turbo's native `turbo_confirm`
|
|
16
|
+
instead. Never stack both confirmation surfaces.
|
|
17
|
+
|
|
18
|
+
## Reviewed deletion
|
|
19
|
+
|
|
20
|
+
Place this composition on the resource's edit page. A show page is the
|
|
21
|
+
operational home; it should not advertise permanent deletion on every visit.
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
module UI
|
|
25
|
+
class DeleteProject < Phlex::HTML
|
|
26
|
+
include Phlex::Rails::Helpers::DOMID
|
|
27
|
+
include Phlex::Rails::Helpers::FormWith
|
|
28
|
+
include Phlex::Rails::Helpers::Routes
|
|
29
|
+
|
|
30
|
+
def initialize(project)
|
|
31
|
+
@project = project
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def view_template
|
|
35
|
+
render NitroKit::DangerZone.new(
|
|
36
|
+
title: "Delete project",
|
|
37
|
+
description: "This permanently removes the project and its activity.",
|
|
38
|
+
id: dom_id(project, :danger_zone)
|
|
39
|
+
) do |zone|
|
|
40
|
+
zone.confirmation do
|
|
41
|
+
render NitroKit::Dialog.new(id: dom_id(project, :delete_dialog)) do |dialog|
|
|
42
|
+
dialog.trigger("Review deletion", variant: :destructive)
|
|
43
|
+
dialog.panel(
|
|
44
|
+
title: "Delete #{project.name}?",
|
|
45
|
+
description: "This action cannot be undone."
|
|
46
|
+
) do
|
|
47
|
+
form_with(
|
|
48
|
+
model: project,
|
|
49
|
+
url: project_path(project),
|
|
50
|
+
method: :delete,
|
|
51
|
+
data: { turbo_frame: "_top" }
|
|
52
|
+
) do
|
|
53
|
+
render NitroKit::Button.new(
|
|
54
|
+
"Delete project",
|
|
55
|
+
type: :submit,
|
|
56
|
+
variant: :destructive,
|
|
57
|
+
data: { turbo_submits_with: "Deleting…" }
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
dialog.close_button(label: "Cancel deletion")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
zone.escape NitroKit::Button.new("Keep project", href: project_path(project))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
attr_reader :project
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The top-level target makes a successful redirect navigate the page rather than trying to render the destination inside the surrounding settings frame.
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
def destroy
|
|
78
|
+
project = Current.account.projects.find(params[:id])
|
|
79
|
+
project.destroy!
|
|
80
|
+
redirect_to projects_path, status: :see_other, notice: "Project deleted"
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Authorize and load the record on the server even when the dialog is open. The confirmation UI is not a security boundary.
|
|
85
|
+
|
|
86
|
+
## Compact confirmation
|
|
87
|
+
|
|
88
|
+
When the consequence fits in one sentence and needs no review UI, keep the ordinary request and use Turbo's native browser confirmation:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
form_with(model: project, url: project_path(project), method: :delete) do
|
|
92
|
+
render NitroKit::Button.new(
|
|
93
|
+
"Delete",
|
|
94
|
+
type: :submit,
|
|
95
|
+
variant: :destructive,
|
|
96
|
+
data: { turbo_confirm: "Delete this project permanently?" }
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Do not stack `turbo_confirm` inside a Dialog. Choose one confirmation surface.
|
|
102
|
+
|
|
103
|
+
## Tests
|
|
104
|
+
|
|
105
|
+
Request-test authorization, deletion, 303 redirect, and flash. System-test the dialog only when the reviewed flow matters: trigger opens it, Cancel closes and restores focus, and the destructive submit removes the record.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Flash and toast
|
|
2
|
+
|
|
3
|
+
Rails flash is the server-side feedback contract. Render it once in the application layout with Nitro's toast adapter so redirects, Turbo visits, and full-page fallbacks all use the same path.
|
|
4
|
+
|
|
5
|
+
## Summary
|
|
6
|
+
|
|
7
|
+
- Render `NitroKit::Toast::FlashMessages` once in the application layout; Rails
|
|
8
|
+
flash stays the single server-side feedback contract.
|
|
9
|
+
- `notice` maps to the default presentation, `alert` and `error` to error, and
|
|
10
|
+
`success`, `warning`, and `info` to their matching variants.
|
|
11
|
+
- Every toast item is Turbo-temporary so a cached page never replays old
|
|
12
|
+
feedback, while the region itself survives and stays addressable.
|
|
13
|
+
- Use `flash.now` when rendering the current request; a Turbo Stream that does
|
|
14
|
+
not redirect updates the same stable notification region.
|
|
15
|
+
- Do not introduce a client-side notification store for server outcomes.
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
class UI::ApplicationLayout < Phlex::HTML
|
|
19
|
+
include Phlex::Rails::Layout
|
|
20
|
+
include Phlex::Rails::Helpers::Flash
|
|
21
|
+
|
|
22
|
+
def view_template
|
|
23
|
+
doctype
|
|
24
|
+
html do
|
|
25
|
+
head do
|
|
26
|
+
stylesheet_link_tag("nitro_kit", data: { turbo_track: "reload" })
|
|
27
|
+
end
|
|
28
|
+
body do
|
|
29
|
+
render NitroKit::Toast::FlashMessages.new(flash: flash)
|
|
30
|
+
yield
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Controllers set ordinary flash while redirecting:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
redirect_to projects_path, status: :see_other, notice: "Project created"
|
|
41
|
+
redirect_to billing_path, status: :see_other, alert: "Payment method was declined"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`notice` maps to the default presentation, `alert` and `error` to error, and `success`, `warning`, and `info` to their matching variants. Every toast item is Turbo-temporary so a cached page does not replay old feedback, dismissible or not. The region itself survives, so `#{toast id}-list` stays addressable.
|
|
45
|
+
|
|
46
|
+
Use `flash.now` only when rendering in the current request:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
flash.now[:alert] = "Import failed"
|
|
50
|
+
render UI::ImportForm.new(@import), status: :unprocessable_entity
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
For a request-scoped Turbo Stream that does not redirect, update a stable notification region in the same stream response. Keep the HTML branch and flash fallback. Do not introduce a client-side notification store for server outcomes.
|
|
54
|
+
|
|
55
|
+
## Tests
|
|
56
|
+
|
|
57
|
+
Controller tests assert the flash severity and message. One layout or integration test should prove the flash renders through `section[data-nk=toast]`. Nitro's own tests cover timers and dismissal behavior.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Inline edit
|
|
2
|
+
|
|
3
|
+
Wrap one complete resource region in a stable Turbo Frame. Show, edit, validation failure, success, and Cancel all return that same frame ID.
|
|
4
|
+
|
|
5
|
+
## Summary
|
|
6
|
+
|
|
7
|
+
- One complete resource region lives in a stable Turbo Frame; show, edit,
|
|
8
|
+
invalid, success, and Cancel all return that same frame ID.
|
|
9
|
+
- Invalid updates render the editing frame with 422; success redirects with 303
|
|
10
|
+
and Turbo extracts the matching read-only frame.
|
|
11
|
+
- The show response must contain the same `turbo_frame_tag(dom_id(record))` as
|
|
12
|
+
the edit response.
|
|
13
|
+
- Use a Turbo Stream only when the update also changes another region, such as
|
|
14
|
+
a page title or a summary count.
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
module UI
|
|
18
|
+
class ProjectPanel < Phlex::HTML
|
|
19
|
+
include Phlex::Rails::Helpers::DOMID
|
|
20
|
+
include Phlex::Rails::Helpers::TurboFrameTag
|
|
21
|
+
|
|
22
|
+
def initialize(project, editing: false)
|
|
23
|
+
@project = project
|
|
24
|
+
@editing = editing
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def view_template
|
|
28
|
+
turbo_frame_tag(dom_id(project)) do
|
|
29
|
+
if editing
|
|
30
|
+
render UI::ProjectFormFields.new(project)
|
|
31
|
+
else
|
|
32
|
+
render NitroKit::Card.new do |card|
|
|
33
|
+
card.title(project.name)
|
|
34
|
+
card.body { project.description }
|
|
35
|
+
card.footer do
|
|
36
|
+
render NitroKit::Button.new("Edit", href: "/projects/#{project.id}/edit")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
attr_reader :project, :editing
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The edit form submits normally within the frame. Its Cancel link targets the resource show action, which returns the read-only frame. Invalid updates render the editing frame with 422. A successful update redirects with 303 to the resource action; Turbo follows the redirect and extracts the matching read-only frame:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
redirect_to @project, status: :see_other, notice: "Project updated"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The show response must contain `turbo_frame_tag(dom_id(project))` just like the edit response. If success renders directly instead, return frame-shaped HTML rather than labeling a bare frame as a Turbo Stream. Use a stream only when the update also changes another region, such as a page title or summary count.
|
|
56
|
+
|
|
57
|
+
## Tests
|
|
58
|
+
|
|
59
|
+
Assert every endpoint returns the same `turbo-frame` ID. Cover invalid values remaining visible at 422 and Cancel restoring the read-only view. Add a system test when focus or scroll preservation is important.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# Queryable collection
|
|
2
|
+
|
|
3
|
+
Use one GET-driven Turbo Frame for filters, sorting, results, and pagination. The URL is the state: the query object reads parameters, the server renders the complete region, and browser history remains useful.
|
|
4
|
+
|
|
5
|
+
## Summary
|
|
6
|
+
|
|
7
|
+
- One GET-driven Turbo Frame with a stable ID owns filters, sorting, results,
|
|
8
|
+
and pagination; the URL is the state.
|
|
9
|
+
- The frame carries `data-turbo-action="advance"` so pagination is a history
|
|
10
|
+
step, while filter, reset, and sort links use `turbo_action: "replace"`.
|
|
11
|
+
- An application query object owns parameter allowlists, default ordering, and
|
|
12
|
+
page bounds. `NitroKit::Table` renders sortable headers but owns no query
|
|
13
|
+
policy, and Ransack is never a Nitro dependency.
|
|
14
|
+
- Every response contains the same frame, including empty results. Links that
|
|
15
|
+
leave the collection use `data: { turbo_frame: "_top" }`.
|
|
16
|
+
- No Stimulus controller is required. An optional autosubmit controller submits
|
|
17
|
+
the same GET form without becoming the source of truth.
|
|
18
|
+
|
|
19
|
+
## Contract
|
|
20
|
+
|
|
21
|
+
- The frame has one stable ID.
|
|
22
|
+
- The frame promotes pagination with `data-turbo-action="advance"` so each page
|
|
23
|
+
remains a meaningful Back and Forward step.
|
|
24
|
+
- Filter forms use GET, target that frame, and replace the current history entry.
|
|
25
|
+
- A small filter set stays in one responsive row at wide widths and stacks on
|
|
26
|
+
narrow screens. Do not turn two fields and two actions into a full-page form.
|
|
27
|
+
- Sort and pagination links preserve the active query parameters. Sort links
|
|
28
|
+
replace the current history entry; pagination inherits the frame's `advance`.
|
|
29
|
+
- `NitroKit::Table` renders the sortable table contract but does not own query policy.
|
|
30
|
+
- The HTML response always contains the same frame, including empty results.
|
|
31
|
+
- No Stimulus controller is required for submit-based filters. An optional autosubmit controller may submit the same GET form without becoming the source of truth.
|
|
32
|
+
|
|
33
|
+
## Controller
|
|
34
|
+
|
|
35
|
+
Keep parameter normalization and allowlists in an application query object. Ransack is one possible implementation, not a Nitro dependency.
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
class ProjectsController < ApplicationController
|
|
39
|
+
def index
|
|
40
|
+
@query = ProjectsQuery.new(Project.all, params: params.fetch(:q, {}), page: params[:page])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The query object should expose `records`, `filters`, `current_sort`, `direction`, `sort_url(key)`, and pagination URLs. It is also the right place for Ransack allowlists, default ordering, and page bounds.
|
|
46
|
+
|
|
47
|
+
## Phlex region
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
module UI
|
|
51
|
+
class ProjectsTable < Phlex::HTML
|
|
52
|
+
include Phlex::Rails::Helpers::FormWith
|
|
53
|
+
include Phlex::Rails::Helpers::TurboFrameTag
|
|
54
|
+
|
|
55
|
+
FRAME_ID = "projects-results"
|
|
56
|
+
|
|
57
|
+
def initialize(query)
|
|
58
|
+
@query = query
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def view_template
|
|
62
|
+
turbo_frame_tag(FRAME_ID, data: { turbo_action: "advance" }) do
|
|
63
|
+
render NitroKit::Flex.new(dir: :col, gap: 6, align: :stretch) do
|
|
64
|
+
render_filters
|
|
65
|
+
render_results
|
|
66
|
+
render_pagination
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
attr_reader :query
|
|
73
|
+
|
|
74
|
+
def render_filters
|
|
75
|
+
form_with(
|
|
76
|
+
scope: :q,
|
|
77
|
+
url: "/projects",
|
|
78
|
+
method: :get,
|
|
79
|
+
builder: NitroKit::FormBuilder,
|
|
80
|
+
data: {
|
|
81
|
+
turbo_frame: FRAME_ID,
|
|
82
|
+
turbo_action: "replace"
|
|
83
|
+
}
|
|
84
|
+
) do |form|
|
|
85
|
+
form.hidden_field(:s, value: query.filters["s"])
|
|
86
|
+
render NitroKit::Grid.new(cols: "1 md:3", gap: 3) do
|
|
87
|
+
form.field(:name_cont, as: :search, label: "Search", value: query.filters["name_cont"])
|
|
88
|
+
form.field(:status_eq, as: :select, label: "Status", options: Project.statuses.keys, include_blank: "All statuses", value: query.filters["status_eq"])
|
|
89
|
+
render NitroKit::Flex.new(dir: :row, gap: 2, align: :end, justify: :end) do
|
|
90
|
+
render NitroKit::Button.new(
|
|
91
|
+
"Reset",
|
|
92
|
+
href: "/projects",
|
|
93
|
+
data: {
|
|
94
|
+
turbo_frame: FRAME_ID,
|
|
95
|
+
turbo_action: "replace"
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
form.submit("Apply filters", data: { turbo_submits_with: "Filtering…" })
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def render_results
|
|
105
|
+
render NitroKit::Table.new(
|
|
106
|
+
sort: query.current_sort,
|
|
107
|
+
direction: query.direction
|
|
108
|
+
) do |table|
|
|
109
|
+
table.caption("Projects")
|
|
110
|
+
table.thead do
|
|
111
|
+
table.tr do
|
|
112
|
+
table.th(
|
|
113
|
+
sort: :name,
|
|
114
|
+
href: query.sort_url(:name),
|
|
115
|
+
sort_data: { turbo_action: "replace" }
|
|
116
|
+
)
|
|
117
|
+
table.th(
|
|
118
|
+
sort: :status,
|
|
119
|
+
href: query.sort_url(:status),
|
|
120
|
+
sort_data: { turbo_action: "replace" }
|
|
121
|
+
)
|
|
122
|
+
table.th(
|
|
123
|
+
"Updated",
|
|
124
|
+
sort: :updated_at,
|
|
125
|
+
href: query.sort_url(:updated_at),
|
|
126
|
+
align: :right,
|
|
127
|
+
sort_data: { turbo_action: "replace" }
|
|
128
|
+
)
|
|
129
|
+
table.th("Actions", align: :right)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
table.tbody do
|
|
133
|
+
if query.records.any?
|
|
134
|
+
query.records.each { |project| render_row(table, project) }
|
|
135
|
+
else
|
|
136
|
+
table.tr { table.td("No projects match these filters.", html: { colspan: 4 }) }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def render_row(table, project)
|
|
143
|
+
table.tr do
|
|
144
|
+
table.th(project.name, scope: :row)
|
|
145
|
+
table.td { render NitroKit::Badge.new(project.status.humanize) }
|
|
146
|
+
table.td(project.updated_at.to_date.to_fs(:long), align: :right)
|
|
147
|
+
table.td(align: :right) do
|
|
148
|
+
render NitroKit::Button.new(
|
|
149
|
+
"View",
|
|
150
|
+
href: project_path(project),
|
|
151
|
+
data: { turbo_frame: "_top" }
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def render_pagination
|
|
158
|
+
render NitroKit::PaginationBar.new do |bar|
|
|
159
|
+
bar.summary(query.summary)
|
|
160
|
+
bar.pagination(query.pagination)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Sorting and pagination links rendered inside the frame naturally navigate it.
|
|
168
|
+
The frame's `advance` action makes pagination a browser-history step. Filters,
|
|
169
|
+
reset links, and sort links override that default with `turbo_action: "replace"`
|
|
170
|
+
so repeated query refinements do not fill history with disposable states. Use
|
|
171
|
+
`data: { turbo_frame: FRAME_ID }` when a query control sits outside the frame.
|
|
172
|
+
Reset with a plain collection URL so stale query parameters disappear.
|
|
173
|
+
|
|
174
|
+
Links that leave the collection, including View, Edit, and New, are not frame
|
|
175
|
+
navigations. Give them `data: { turbo_frame: "_top" }`, or place them outside
|
|
176
|
+
the results frame. Otherwise Turbo will look for the collection frame in the
|
|
177
|
+
destination response and replace the region with “Content missing.”
|
|
178
|
+
|
|
179
|
+
## Tests
|
|
180
|
+
|
|
181
|
+
Request tests should prove that query parameters survive sort and page changes,
|
|
182
|
+
invalid sort keys fall back safely, and the response always contains
|
|
183
|
+
`turbo-frame#projects-results`. A system test should use real clicks, scope
|
|
184
|
+
result assertions to that frame, cover filter → sort → paginate → Back and
|
|
185
|
+
Forward, assert the address bar after each query transition, and open a row
|
|
186
|
+
action without a frame-missing error. Use Capybara's retrying assertions rather
|
|
187
|
+
than sleeps or arbitrary waits.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Resource form
|
|
2
|
+
|
|
3
|
+
Use one model-backed component for both the initial form and validation errors. Rails owns field names and errors; Nitro owns form presentation; the response status tells Turbo whether to replace or follow a redirect.
|
|
4
|
+
|
|
5
|
+
## Summary
|
|
6
|
+
|
|
7
|
+
- One model-backed Phlex component renders both the initial form and its
|
|
8
|
+
validation errors.
|
|
9
|
+
- Rails owns field names and errors, Nitro owns form presentation, and the
|
|
10
|
+
response status tells Turbo what to do: 422 re-renders the invalid object,
|
|
11
|
+
303 redirects on success.
|
|
12
|
+
- In an application shell, render exactly one primary submit in the route
|
|
13
|
+
toolbar and associate it through the native `form:` attribute; do not also
|
|
14
|
+
call `form.submit` in the body.
|
|
15
|
+
- A genuinely standalone form keeps its submit inside the same `form.group` as
|
|
16
|
+
its visible fields.
|
|
17
|
+
- Parents own rhythm: stacked fields always sit inside `FieldGroup`
|
|
18
|
+
(`form.group` on the builder, or `render NitroKit::FieldGroup.new`). Fields
|
|
19
|
+
rendered as bare siblings of the `form` element stack with no gap — that is
|
|
20
|
+
an authoring mistake, not a layout the field can correct itself.
|
|
21
|
+
- Wrap the form in a Turbo Frame only when it is embedded in a larger screen
|
|
22
|
+
and needs an independent lifecycle.
|
|
23
|
+
|
|
24
|
+
## Phlex form
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
module UI
|
|
28
|
+
class ProjectForm < Phlex::HTML
|
|
29
|
+
include Phlex::Rails::Helpers::FormWith
|
|
30
|
+
|
|
31
|
+
def initialize(
|
|
32
|
+
project,
|
|
33
|
+
form_id: ActionView::RecordIdentifier.dom_id(project, :form)
|
|
34
|
+
)
|
|
35
|
+
@project = project
|
|
36
|
+
@form_id = form_id
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def view_template
|
|
40
|
+
render NitroKit::FormSection.new(
|
|
41
|
+
title: project.persisted? ? "Edit project" : "New project",
|
|
42
|
+
description: "Project details are visible to every workspace member."
|
|
43
|
+
) do |section|
|
|
44
|
+
section.form do
|
|
45
|
+
form_with(
|
|
46
|
+
model: project,
|
|
47
|
+
builder: NitroKit::FormBuilder,
|
|
48
|
+
id: form_id
|
|
49
|
+
) do |form|
|
|
50
|
+
form.group do
|
|
51
|
+
form.field(:name, required: true, autofocus: true)
|
|
52
|
+
form.field(
|
|
53
|
+
:status,
|
|
54
|
+
as: :select,
|
|
55
|
+
options: Project.statuses.keys.map do |value|
|
|
56
|
+
[ value.humanize, value ]
|
|
57
|
+
end
|
|
58
|
+
)
|
|
59
|
+
form.field(:description, as: :textarea)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
attr_reader :project, :form_id
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
In an application shell, render exactly one primary submit in the route
|
|
73
|
+
toolbar and associate it with `form_id`:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
Button(
|
|
77
|
+
project.persisted? ? "Save project" : "Create project",
|
|
78
|
+
type: :submit,
|
|
79
|
+
form: form_id,
|
|
80
|
+
variant: :primary,
|
|
81
|
+
data: { turbo_submits_with: "Saving…" }
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Do not also call `form.submit` in the form body. Use an in-form submit only
|
|
86
|
+
when the form is genuinely standalone and has no toolbar action; put that
|
|
87
|
+
submit inside the same `form.group` as its visible fields.
|
|
88
|
+
|
|
89
|
+
## Controller
|
|
90
|
+
|
|
91
|
+
Render the same invalid object with 422. Redirect successful HTML submissions with 303.
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
class ProjectsController < ApplicationController
|
|
95
|
+
def create
|
|
96
|
+
@project = Current.account.projects.build(project_params)
|
|
97
|
+
|
|
98
|
+
if @project.save
|
|
99
|
+
redirect_to @project, status: :see_other, notice: "Project created"
|
|
100
|
+
else
|
|
101
|
+
render UI::ProjectForm.new(@project), status: :unprocessable_entity
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def update
|
|
106
|
+
@project = Current.account.projects.find(params[:id])
|
|
107
|
+
|
|
108
|
+
if @project.update(project_params)
|
|
109
|
+
redirect_to @project, status: :see_other, notice: "Project updated"
|
|
110
|
+
else
|
|
111
|
+
render UI::ProjectForm.new(@project), status: :unprocessable_entity
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
def project_params
|
|
117
|
+
params.expect(project: [:name, :status, :description])
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The same response works with Turbo Drive and without it. Wrap this component in a stable Turbo Frame only when the form is embedded in a larger screen and should have an independent lifecycle; [inline edit](inline_edit.md) shows that boundary. Add a Turbo Stream branch only when success must update multiple regions instead of navigating.
|
|
123
|
+
|
|
124
|
+
## Tests
|
|
125
|
+
|
|
126
|
+
Assert 303 plus the redirect and flash on success. Assert 422, the submitted value, and the model error on failure. For an embedded form, also assert the stable frame ID. Add a system test for focus or multi-region effects, not merely to restate controller behavior.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# The Nitro Kit Rails path
|
|
2
|
+
|
|
3
|
+
Nitro Kit 2 follows one conventional Rails path so application code, UI, and
|
|
4
|
+
agent output compose predictably. Treat this as the greenfield default. In an
|
|
5
|
+
existing application, preserve established conventions unless the task
|
|
6
|
+
explicitly includes changing them.
|
|
7
|
+
|
|
8
|
+
## Model the domain with Rails
|
|
9
|
+
|
|
10
|
+
- Put business rules on domain models. Do not introduce a service, form,
|
|
11
|
+
command, or policy object until a real boundary requires one.
|
|
12
|
+
- Scope tenant-owned records through `Current.team` or `Current.account`.
|
|
13
|
+
Record `Current.user` as the actor or creator instead of using the user as
|
|
14
|
+
the tenancy boundary.
|
|
15
|
+
- For a new team-aware application, use `User`, `Team`, and `Membership` from
|
|
16
|
+
the first signup. Put roles on the membership and create the first user's
|
|
17
|
+
team plus owner membership in the same transaction. Read
|
|
18
|
+
`docs/patterns/application_foundation.md` before adding authentication,
|
|
19
|
+
invitations, team administration, or account settings.
|
|
20
|
+
- Represent meaningful lifecycle state with a record when the state has
|
|
21
|
+
identity, timing, provenance, or behavior. A published post has a
|
|
22
|
+
`Post::Publication`; it is not merely a `published` boolean.
|
|
23
|
+
- Name capability concerns with adjectives such as `Publishable` only after
|
|
24
|
+
more than one model shares the behavior. Keep the first implementation on
|
|
25
|
+
the model that owns it.
|
|
26
|
+
|
|
27
|
+
## Route nouns, not commands
|
|
28
|
+
|
|
29
|
+
Map controllers to REST resources. When an action does not fit the seven
|
|
30
|
+
standard actions, look for the missing noun:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
namespace :admin do
|
|
34
|
+
resources :posts do
|
|
35
|
+
resource :publication, only: %i[create destroy],
|
|
36
|
+
module: :posts
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`Admin::Posts::PublicationsController#create` publishes and `#destroy`
|
|
42
|
+
unpublishes. Keep controllers thin: load through the tenant boundary, invoke a
|
|
43
|
+
domain method, then render or redirect.
|
|
44
|
+
|
|
45
|
+
Use the authenticated admin `show` action as the operational detail or draft
|
|
46
|
+
preview unless the product truly has another independently addressable
|
|
47
|
+
representation. Public controllers must query only records visible to the
|
|
48
|
+
public.
|
|
49
|
+
|
|
50
|
+
## Use ordinary Rails responses
|
|
51
|
+
|
|
52
|
+
- Use `form_with` and `NitroKit::FormBuilder` for model-backed forms.
|
|
53
|
+
- Redirect successful non-GET submissions with `status: :see_other`.
|
|
54
|
+
- Render the same invalid model with `status: :unprocessable_entity`.
|
|
55
|
+
- Keep an HTML response for every Turbo-enhanced flow.
|
|
56
|
+
- Keep filtering, sorting, and pagination in GET parameters.
|
|
57
|
+
- Set flash on the server and render it through
|
|
58
|
+
`NitroKit::Toast::FlashMessages`.
|
|
59
|
+
|
|
60
|
+
## Compose CRUD as one page
|
|
61
|
+
|
|
62
|
+
For an authenticated admin or product area, prefer the hybrid `AppShell` with
|
|
63
|
+
a `Toolbar` in its topbar. The shell supplies responsive navigation disclosure;
|
|
64
|
+
the toolbar supplies the route's one `h1` and its basic actions. The application
|
|
65
|
+
owns one content gutter immediately inside the shell main region.
|
|
66
|
+
|
|
67
|
+
Do not repeat the route title in a `PageHeader`, section heading, card title,
|
|
68
|
+
and table caption. Use `PageHeader` inside an application shell only when the
|
|
69
|
+
page needs a genuinely content-led introduction beyond the toolbar title. A
|
|
70
|
+
single-table index renders the table directly. A form page begins with the
|
|
71
|
+
actual `FormSection`. A detail page begins with status or metadata. Use a Card
|
|
72
|
+
only for a bounded object that needs a surface, never as the automatic wrapper
|
|
73
|
+
for every region.
|
|
74
|
+
|
|
75
|
+
Read `docs/patterns/crud_resource.md` before building a complete resource.
|
|
76
|
+
|
|
77
|
+
## Keep the browser thin
|
|
78
|
+
|
|
79
|
+
Render HTML on the server. Use Turbo Drive by default, Frames for one stable
|
|
80
|
+
region, request Streams for multi-target responses, broadcasts for other
|
|
81
|
+
sessions, and Stimulus only for browser-owned behavior. Do not create a JSON
|
|
82
|
+
API or client-side state store for an ordinary Rails screen.
|
|
83
|
+
|
|
84
|
+
## Test the contract
|
|
85
|
+
|
|
86
|
+
Use Minitest and fixtures. Cover:
|
|
87
|
+
|
|
88
|
+
- tenant isolation and role differences;
|
|
89
|
+
- public visibility of drafts and published records;
|
|
90
|
+
- successful `303` redirects and invalid `422` renders;
|
|
91
|
+
- the noun resource that creates or removes lifecycle state;
|
|
92
|
+
- the HTML fallback before adding a system test;
|
|
93
|
+
- browser behavior with Capybara waiting assertions, never sleeps.
|
|
94
|
+
|
|
95
|
+
Prefer a small complete test over layers of factories, mocks, and helper DSLs.
|