nitro_kit 0.9.0 → 2.0.0.alpha.1
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 +151 -0
- data/LICENSE +28 -0
- data/README.md +21 -16
- data/STYLE_GUIDE.md +425 -0
- data/app/assets/stylesheets/nitro_kit-tailwind-v4.css +38 -0
- data/app/assets/stylesheets/nitro_kit.css +7225 -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 +128 -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 +104 -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/implementation_plan.md +279 -0
- data/docs/initialization_prompt.md +36 -0
- data/docs/migration_1_to_2.md +318 -0
- data/docs/new_app_strategy.md +24 -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 +189 -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 +62 -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 +110 -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 +183 -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,136 @@
|
|
|
1
|
+
# A durable application foundation
|
|
2
|
+
|
|
3
|
+
Start a small authenticated application with the same records and page grammar
|
|
4
|
+
it will need after the second person joins. The first-user case should be the
|
|
5
|
+
smallest instance of the team model, not a separate personal mode that must be
|
|
6
|
+
replaced later.
|
|
7
|
+
|
|
8
|
+
## Summary
|
|
9
|
+
|
|
10
|
+
- Model `User`, `Team`, and `Membership` from the first signup; role belongs to
|
|
11
|
+
`Membership`, and every team-owned record loads through `Current.team`.
|
|
12
|
+
- One hybrid `AppShell` frames the authenticated product: `AppNavigation` owns
|
|
13
|
+
brand and destinations, the shell `Toolbar` owns route titles and persistent
|
|
14
|
+
basic actions.
|
|
15
|
+
- One wrapper immediately inside `shell.main` owns responsive page padding for
|
|
16
|
+
every route; the shell owns viewport height and scrolling.
|
|
17
|
+
- Infrequent account destinations go after `navigation.spacer`; settings
|
|
18
|
+
compose with `SettingsLayout` and plain `FormSection` regions.
|
|
19
|
+
- When destination count warrants search, compose one `CommandPalette` in the
|
|
20
|
+
shell and render only routes the current membership may visit.
|
|
21
|
+
- Centralize cross-cutting feedback in one flash-driven toast region rendered
|
|
22
|
+
by the layout.
|
|
23
|
+
|
|
24
|
+
## Use memberships from the first user
|
|
25
|
+
|
|
26
|
+
Use `User`, `Team`, and `Membership` even when signup creates exactly one of
|
|
27
|
+
each. Put the role on `Membership`, not `User`, because authority belongs to a
|
|
28
|
+
person's relationship with a team. Create the first team and owner membership
|
|
29
|
+
in the same transaction as signup.
|
|
30
|
+
|
|
31
|
+
Set `Current.user`, `Current.membership`, and `Current.team` from the session.
|
|
32
|
+
Load every team-owned record through `Current.team`; use `Current.user` for
|
|
33
|
+
authorship and audit fields. Start with the smallest role vocabulary the
|
|
34
|
+
product needs, usually owner, administrator, and member. Protect the last owner
|
|
35
|
+
in the domain model rather than only hiding a button.
|
|
36
|
+
|
|
37
|
+
Invitations should belong to a team, record the inviter and intended role,
|
|
38
|
+
expire, match the invited email, and be consumed when accepted. An existing
|
|
39
|
+
user accepts into a new membership; a new user completes signup and then uses
|
|
40
|
+
the same acceptance path.
|
|
41
|
+
|
|
42
|
+
## Compose one authenticated frame
|
|
43
|
+
|
|
44
|
+
Use one hybrid `AppShell` for the authenticated product. Keep the brand and
|
|
45
|
+
primary destinations in `AppNavigation`; place route titles and persistent
|
|
46
|
+
basic actions in the shell `Toolbar`. One wrapper immediately inside
|
|
47
|
+
`shell.main` owns responsive page padding for every route. The topbar and
|
|
48
|
+
sidebar header should use the shell's shared height and border tokens rather
|
|
49
|
+
than independent padding guesses.
|
|
50
|
+
|
|
51
|
+
Put infrequent account navigation after `navigation.spacer`, near the account
|
|
52
|
+
controls at the bottom of the sidebar:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
shell.navigation do
|
|
56
|
+
AppNavigation(label: "Workspace navigation") do |navigation|
|
|
57
|
+
navigation.body do
|
|
58
|
+
navigation.item("Inventory", href: assets_path, icon: :archive)
|
|
59
|
+
navigation.item("Team", href: team_path, icon: :users)
|
|
60
|
+
navigation.spacer
|
|
61
|
+
navigation.item("Settings", href: settings_profile_path, icon: :settings)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Application code owns destinations and current-route policy. Nitro owns shell
|
|
68
|
+
layout, mobile disclosure, focus management, and navigation semantics.
|
|
69
|
+
For larger products, place one `CommandPalette` in the shell. Its native links
|
|
70
|
+
remain the navigation authority while Command-K or Control-K adds fast
|
|
71
|
+
filtering. Render the same authorized destination set the user can reach in
|
|
72
|
+
ordinary navigation; do not use the palette to bypass route policy.
|
|
73
|
+
When the destination set is too large or dynamic to render eagerly, pass
|
|
74
|
+
`search_url:` and return `CommandPalette::Results` from that endpoint. Keep the
|
|
75
|
+
same authorization scope on the initial links and every remote query.
|
|
76
|
+
Let the shell own viewport height and scrolling; do not add `min-height: 100vh`
|
|
77
|
+
to its main region or page wrapper. Put brand and destination icons through the
|
|
78
|
+
navigation slots so they share the same left alignment.
|
|
79
|
+
|
|
80
|
+
Team is an administration surface, not merely a roster. Include pending
|
|
81
|
+
invitations and the complete invite, role-change, removal, and revoke paths,
|
|
82
|
+
with last-owner protection in the model and authorization on every mutation.
|
|
83
|
+
|
|
84
|
+
Authentication is a standalone form surface. Inside `AuthShell`, use Rails
|
|
85
|
+
`form_with` with `NitroKit::FormBuilder` and put the visible fields, submit
|
|
86
|
+
control, and related recovery link in one `form.group`. The group owns their
|
|
87
|
+
vertical rhythm; `AuthShell` owns only the page container and spacing between
|
|
88
|
+
its major regions.
|
|
89
|
+
|
|
90
|
+
## Keep settings plain
|
|
91
|
+
|
|
92
|
+
Use `SettingsLayout` inside the normal shell main region. Its navigation lists
|
|
93
|
+
stable subsections such as Profile, Notifications, Appearance, and Password;
|
|
94
|
+
its content renders the selected form. Use `FormSection` for genuinely distinct
|
|
95
|
+
form regions and ordinary whitespace or dividers between them.
|
|
96
|
+
|
|
97
|
+
Render subsection destinations as links and mark the active link with
|
|
98
|
+
`aria-current="page"`. They navigate between routes; Buttons and ButtonGroup
|
|
99
|
+
would incorrectly present them as in-page actions. Small preferences may
|
|
100
|
+
submit on change through a tiny application Stimulus controller that calls the
|
|
101
|
+
form's native `requestSubmit`. Keep a submit control in `noscript` so the form
|
|
102
|
+
still works without JavaScript.
|
|
103
|
+
|
|
104
|
+
The route still has one `h1` in the shell toolbar. Do not repeat “Settings” in
|
|
105
|
+
the page body, wrap each subsection in a Card, or give every form its own outer
|
|
106
|
+
padding. A toolbar Save button can submit the selected form with the native
|
|
107
|
+
`form:` attribute, so the action stays in the same place at narrow and wide
|
|
108
|
+
widths without JavaScript.
|
|
109
|
+
|
|
110
|
+
## Centralize cross-cutting feedback
|
|
111
|
+
|
|
112
|
+
Render `Toast::FlashMessages` once in the application layout. Keep using
|
|
113
|
+
ordinary Rails flash and `303 See Other` redirects from controllers.
|
|
114
|
+
|
|
115
|
+
Compact destructive actions should continue to declare
|
|
116
|
+
`data: { turbo_confirm: "…" }` and use Turbo's native browser confirmation.
|
|
117
|
+
When the user needs branded review UI or more context than one sentence,
|
|
118
|
+
compose a dedicated native Nitro `Dialog` at the action's call site. The
|
|
119
|
+
browser's top layer keeps that inline dialog clear of ancestor clipping and
|
|
120
|
+
stacking contexts. Put record deletion on the edit route rather than adding a
|
|
121
|
+
danger surface to every operational show page.
|
|
122
|
+
|
|
123
|
+
## Baseline acceptance path
|
|
124
|
+
|
|
125
|
+
Before polishing empty-state illustration or dashboard summaries, verify:
|
|
126
|
+
|
|
127
|
+
- signup or sign-in selects the current membership and team;
|
|
128
|
+
- another team cannot load the current team's records;
|
|
129
|
+
- owner, administrator, and member policy differs where intended;
|
|
130
|
+
- populated, empty, invalid, narrow, and destructive states work;
|
|
131
|
+
- settings forms preserve validation and use one content gutter;
|
|
132
|
+
- successful mutations redirect with `303` and invalid forms render with
|
|
133
|
+
`422`;
|
|
134
|
+
- confirmation has both cancel and confirm coverage;
|
|
135
|
+
- the shell, headings, tables, and forms remain usable without custom request
|
|
136
|
+
JavaScript.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# A complete product resource
|
|
2
|
+
|
|
3
|
+
Build CRUD as one coherent product surface, not independent generated screens.
|
|
4
|
+
Before writing the views, name the resource, tenant boundary, actor, lifecycle,
|
|
5
|
+
visibility rules, and states. Implement index, form, detail, destructive action,
|
|
6
|
+
and their tests together.
|
|
7
|
+
|
|
8
|
+
## Summary
|
|
9
|
+
|
|
10
|
+
- Build index, form, detail, destructive action, and their tests as one
|
|
11
|
+
surface; name the resource, tenant boundary, actor, lifecycle, and states
|
|
12
|
+
before writing views.
|
|
13
|
+
- `AppShell(layout: :hybrid)` frames the admin area, and a `Toolbar` in
|
|
14
|
+
`shell.topbar` carries the route's single `h1` plus its basic actions.
|
|
15
|
+
- Spend hierarchy once: one route, one `h1`, an `h2` only for a genuinely
|
|
16
|
+
separate region, and no resource name repeated across toolbar, `PageHeader`,
|
|
17
|
+
`DataSection`, Card, and caption.
|
|
18
|
+
- Child routes add one compact icon Back link before the title instead of a
|
|
19
|
+
trailing Cancel action.
|
|
20
|
+
- One responsive padding rule on the main wrapper owns the page gutter;
|
|
21
|
+
data-heavy hybrid CRUD content uses the full available width.
|
|
22
|
+
|
|
23
|
+
## Use the hybrid application frame
|
|
24
|
+
|
|
25
|
+
An authenticated admin area defaults to `AppShell(layout: :hybrid)`. Nitro Kit
|
|
26
|
+
owns the desktop sidebar, mobile menu button, navigation dialog, focus behavior,
|
|
27
|
+
and responsive transition. The application provides destinations and current
|
|
28
|
+
state.
|
|
29
|
+
|
|
30
|
+
Put a `Toolbar` in `shell.topbar`. Its leading region contains the route's one
|
|
31
|
+
`h1`; its trailing region contains basic actions such as New, Edit, Cancel,
|
|
32
|
+
Save, Publish, or View. A Button outside a form can submit it through the
|
|
33
|
+
native `form:` attribute. This keeps the same action hierarchy on narrow and
|
|
34
|
+
wide screens without custom JavaScript.
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
AppShell(id: "admin", layout: :hybrid) do |shell|
|
|
38
|
+
shell.navigation { render admin_navigation }
|
|
39
|
+
shell.topbar do
|
|
40
|
+
Toolbar do |toolbar|
|
|
41
|
+
toolbar.leading { h1 { page_title } }
|
|
42
|
+
toolbar.trailing do
|
|
43
|
+
Button(
|
|
44
|
+
"Save",
|
|
45
|
+
type: :submit,
|
|
46
|
+
form: dom_id(@post, :form),
|
|
47
|
+
variant: :primary
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
shell.main do
|
|
53
|
+
div(data: { ui: "admin-main" }) do
|
|
54
|
+
render page
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Child routes add one compact Back link before the title. Prefer an icon-only
|
|
61
|
+
Button with an explicit label such as `aria: { label: "Back to projects" }`.
|
|
62
|
+
Do not repeat that navigation as a trailing Cancel action.
|
|
63
|
+
|
|
64
|
+
The application stylesheet gives `admin-main` one responsive padding rule.
|
|
65
|
+
Child pages do not add another outer gutter. Keep data-heavy hybrid CRUD
|
|
66
|
+
content full width; constrain only a specific content-led region whose measure
|
|
67
|
+
benefits from it.
|
|
68
|
+
|
|
69
|
+
Do not add viewport height or another outer padding rule to `admin-main`; the
|
|
70
|
+
shell owns viewport geometry and the wrapper owns the one page gutter. Use the
|
|
71
|
+
same shell and gutter on team administration and settings routes.
|
|
72
|
+
Place a bottom-anchored Settings destination after `AppNavigation#spacer`, then
|
|
73
|
+
compose settings subsections with `SettingsLayout` and plain `FormSection`
|
|
74
|
+
regions. Settings destinations are links with `aria-current`, not action
|
|
75
|
+
Buttons. Read `application_foundation.md` for the complete application frame.
|
|
76
|
+
|
|
77
|
+
## Spend hierarchy once
|
|
78
|
+
|
|
79
|
+
- One route, one `h1`, normally in the shell toolbar.
|
|
80
|
+
- Add an `h2` only for a genuinely separate region.
|
|
81
|
+
- Do not repeat “Posts” in the toolbar, `PageHeader`, `DataSection`, Card, and
|
|
82
|
+
visible table caption.
|
|
83
|
+
- Use `PageHeader` for a content-led introduction, not as mandatory CRUD
|
|
84
|
+
ceremony under an existing toolbar.
|
|
85
|
+
- Prefer whitespace and dividers. Use Card only for a bounded object that
|
|
86
|
+
benefits from its own surface. Never default to Card inside Card.
|
|
87
|
+
|
|
88
|
+
An index begins with a short introduction only if it adds useful context, then
|
|
89
|
+
renders its table and pagination directly. Use `DataSection` when a page has
|
|
90
|
+
multiple independently named datasets. At zero records, replace the data region
|
|
91
|
+
with one intentional `EmptyState`; keep the primary New action in the toolbar.
|
|
92
|
+
|
|
93
|
+
A new or edit page begins with `FormSection`. Use one form component for new,
|
|
94
|
+
edit, and invalid renders. Put the primary submit in the toolbar by setting the
|
|
95
|
+
button's `form:` to the form's stable DOM ID. Invalid submissions render the
|
|
96
|
+
same model and form with `422 Unprocessable Entity`. The toolbar owns that
|
|
97
|
+
action: do not render a second Save or Create submit inside the form body.
|
|
98
|
+
|
|
99
|
+
A detail page begins with status or stable metadata, then the resource itself.
|
|
100
|
+
Keep status inside that normal details flow instead of detaching it into a
|
|
101
|
+
second side panel.
|
|
102
|
+
Use the authenticated `show` route as the operational detail or draft preview.
|
|
103
|
+
Put lifecycle forms in the page and associate their toolbar buttons with
|
|
104
|
+
`form:`. Put destructive confirmation in one separate `DangerZone` on edit,
|
|
105
|
+
with a safe escape back to the record. Do not make every show page end in a
|
|
106
|
+
large deletion surface.
|
|
107
|
+
|
|
108
|
+
## Model and route the lifecycle
|
|
109
|
+
|
|
110
|
+
Scope every lookup through `Current.team` or `Current.account`. Record
|
|
111
|
+
`Current.user` as author, creator, or publisher. If a state has provenance,
|
|
112
|
+
timing, or behavior, model it as a record and expose it as a noun resource:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
namespace :admin do
|
|
116
|
+
resources :posts do
|
|
117
|
+
resource :publication,
|
|
118
|
+
only: %i[create destroy],
|
|
119
|
+
module: :posts
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The main controller keeps the seven REST actions. Successful mutations redirect
|
|
125
|
+
with `303 See Other`. Publication create and destroy invoke domain methods and
|
|
126
|
+
redirect. Public controllers query published records only. Ordinary Rails forms
|
|
127
|
+
and Turbo Drive are the default; do not add fetch code for CRUD submissions.
|
|
128
|
+
|
|
129
|
+
## Ship the acceptance path
|
|
130
|
+
|
|
131
|
+
Request tests cover tenant isolation, public visibility, successful `303`
|
|
132
|
+
redirects, invalid `422` renders, pagination, and lifecycle resources. Rendering
|
|
133
|
+
assertions should also protect the high-level composition: hybrid AppShell,
|
|
134
|
+
one `h1`, navigation, toolbar action, actual form association, table or empty
|
|
135
|
+
state, and destructive confirmation. Add one browser test for the meaningful
|
|
136
|
+
end-to-end path, using Capybara waiting assertions instead of sleeps.
|
|
137
|
+
|
|
138
|
+
Assert that each primary action has one visible control. A toolbar-associated
|
|
139
|
+
form submit plus an identical body submit is duplication, even when both invoke
|
|
140
|
+
the same valid form.
|
|
141
|
+
|
|
142
|
+
Before finishing, inspect a populated index, empty index, invalid form, narrow
|
|
143
|
+
form, draft detail, published detail, and edit-owned destructive dialog. Remove any extra
|
|
144
|
+
heading, surface, wrapper, or page gutter that does not communicate information.
|
|
@@ -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.
|