hanami-turbo 0.1.0
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 +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.md +9 -0
- data/README.md +415 -0
- data/lib/hanami/turbo/action.rb +28 -0
- data/lib/hanami/turbo/dom_id.rb +81 -0
- data/lib/hanami/turbo/helpers.rb +129 -0
- data/lib/hanami/turbo/part.rb +29 -0
- data/lib/hanami/turbo/request.rb +45 -0
- data/lib/hanami/turbo/response.rb +83 -0
- data/lib/hanami/turbo/stream_builder.rb +169 -0
- data/lib/hanami/turbo/version.rb +7 -0
- data/lib/hanami/turbo/view_internals.rb +36 -0
- data/lib/hanami/turbo.rb +91 -0
- metadata +96 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 25698bfc47dcbbad1d9fe915efc56b9baff1286e5d8f74137baeb704f90d3562
|
|
4
|
+
data.tar.gz: b9daa12a59ac36e46a98a23d2e5558ec2af132db1a18d8ee8372a64ffd112dba
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2bf61aa86174a526b17b5a08b48d888b75b424c172d89f2fffca79bddaa7e8c1e0ca0fa310312851097dfd2d96b845ace92fd856e866cf38be756f5e0f1494db
|
|
7
|
+
data.tar.gz: c92a9bd136e2b83dfce19c839d5cc038ff61e51e9c3b890bf9ccc86997dfca4d20eb4dc3af951e1e57d537abecd24aec57cd6d0a92ec250006dfc19a86cd64a9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Initial release.
|
|
6
|
+
- Add `button_to`, for one-button forms with Turbo confirmations (`data: {turbo_confirm: "..."}`).
|
|
7
|
+
- Support custom stream actions: include a module into `Hanami::Turbo::StreamBuilder`. Actions may have no target, and `turbo_stream_action_tag` matches turbo-rails.
|
data/LICENSE.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright © 2026 Andrea Fomera
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
# hanami-turbo
|
|
2
|
+
|
|
3
|
+
[Hotwire Turbo](https://turbo.hotwired.dev) support for Hanami apps.
|
|
4
|
+
|
|
5
|
+
- Renders `.turbo_stream` templates for Turbo Stream requests (without a layout), falling back to
|
|
6
|
+
HTML when a view has no stream template.
|
|
7
|
+
- A `turbo_stream` builder, `turbo_frame_tag`, and `dom_id`/`dom_class` helpers for templates.
|
|
8
|
+
- `button_to`, for buttons that submit (with Turbo confirmations, like Rails).
|
|
9
|
+
- [Custom stream actions](#custom-stream-actions), added like in turbo-rails
|
|
10
|
+
(`turbo_stream_action_tag` included).
|
|
11
|
+
- `303 See Other` redirects after non-GET requests, as Turbo requires.
|
|
12
|
+
- Optional lighter layouts for Turbo Frame requests.
|
|
13
|
+
|
|
14
|
+
Built against the main branches of Hanami and its gems. See
|
|
15
|
+
[hanami-turbo-example](../hanami-turbo-example) for a complete app using every feature.
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
# Gemfile
|
|
21
|
+
gem "hanami-turbo"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# config/app.rb
|
|
26
|
+
require "hanami"
|
|
27
|
+
require "hanami/turbo"
|
|
28
|
+
|
|
29
|
+
module Bookshelf
|
|
30
|
+
class App < Hanami::App
|
|
31
|
+
Hanami::Turbo.install(config)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`install` registers the `turbo_stream` format (`text/vnd.turbo-stream.html`) and adds it to your
|
|
37
|
+
accepted formats. If your app accepts any format (the default), it keeps doing so.
|
|
38
|
+
|
|
39
|
+
Then add Turbo to your JavaScript, and the CSRF meta tags to your layout, so Turbo can send the
|
|
40
|
+
token with requests made from links and buttons:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npm install @hotwired/turbo
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
// app/assets/js/app.js
|
|
48
|
+
import "@hotwired/turbo";
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```erb
|
|
52
|
+
<%# app/templates/layouts/app.html.erb, inside <head> %>
|
|
53
|
+
<%= csrf_meta_tags&.html_safe %>
|
|
54
|
+
<%= javascript_tag "app", defer: true %>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Load the script in `<head>` (with `defer`), not at the end of `<body>`: Turbo replaces the body on
|
|
58
|
+
each visit, and would otherwise evaluate it again.
|
|
59
|
+
|
|
60
|
+
On Hanami main, `csrf_meta_tags` returns a string that isn't marked HTML safe, so it is escaped
|
|
61
|
+
unless you call `html_safe` as above. (Sessions must be enabled for CSRF tokens to exist.)
|
|
62
|
+
|
|
63
|
+
## Turbo Streams
|
|
64
|
+
|
|
65
|
+
Turbo form submissions accept stream responses. When a view has a `.turbo_stream` template, it is
|
|
66
|
+
rendered for these requests, with no layout:
|
|
67
|
+
|
|
68
|
+
```erb
|
|
69
|
+
<%# app/templates/books/create.turbo_stream.erb %>
|
|
70
|
+
<%= turbo_stream.prepend "books", partial: "books/book", locals: {book:} %>
|
|
71
|
+
<%= turbo_stream.update "books_count", books_count.to_s %>
|
|
72
|
+
<%= turbo_stream.replace "new_book" do %>
|
|
73
|
+
<%= render "books/form" %>
|
|
74
|
+
<% end %>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Views without a `.turbo_stream` template render HTML as usual, so you can add stream templates only
|
|
78
|
+
where you want them.
|
|
79
|
+
|
|
80
|
+
Partials render in the stream format when one exists (`_book.turbo_stream.erb`), and otherwise as
|
|
81
|
+
HTML, so the same `_book.html.erb` serves full pages and streams.
|
|
82
|
+
|
|
83
|
+
The builder supports every Turbo action: `append`, `prepend`, `replace`, `update`, `before`,
|
|
84
|
+
`after`, `remove` and `refresh`, plus `*_all` variants that take a CSS selector (`update_all
|
|
85
|
+
".count", "3"`), `method: :morph`, and `action(:custom_action, target, ...)` for custom actions.
|
|
86
|
+
Targets can be strings or records.
|
|
87
|
+
|
|
88
|
+
A common shape for actions that respond to Turbo with streams, and redirect everyone else:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
def handle(request, response)
|
|
92
|
+
book = book_repo.create(request.params[:book])
|
|
93
|
+
|
|
94
|
+
if request.turbo_stream?
|
|
95
|
+
response.render(view, book:) # renders create.turbo_stream.erb
|
|
96
|
+
else
|
|
97
|
+
response.redirect_to routes.path(:books)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Error responses that weren't rendered from a stream template (such as `halt 404`) are sent as HTML
|
|
103
|
+
even when the request negotiated a stream, so Turbo shows them instead of silently ignoring them.
|
|
104
|
+
|
|
105
|
+
Custom actions (your own, beyond Turbo's built-ins) are covered in
|
|
106
|
+
[Custom stream actions](#custom-stream-actions) below.
|
|
107
|
+
|
|
108
|
+
To pick a format yourself, pass it to `render`; explicit options always win:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
res.render(view, format: :html)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Custom stream actions
|
|
115
|
+
|
|
116
|
+
Turbo's built-in actions cover changing the DOM, but you can add your own, such as showing a toast,
|
|
117
|
+
highlighting an element, or logging to the console. A custom action has two halves:
|
|
118
|
+
|
|
119
|
+
1. **Ruby**: a method on the `turbo_stream` builder that renders `<turbo-stream action="...">`.
|
|
120
|
+
2. **JavaScript**: a function on `Turbo.StreamActions` that runs when that element arrives.
|
|
121
|
+
|
|
122
|
+
This works the way it does in turbo-rails, and modules written for turbo-rails can be reused as they
|
|
123
|
+
are (see [Porting from turbo-rails](#porting-from-turbo-rails)).
|
|
124
|
+
|
|
125
|
+
### 1. Define the actions
|
|
126
|
+
|
|
127
|
+
Write a module of methods, and include it into `Hanami::Turbo::StreamBuilder`:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
# config/turbo_stream_actions.rb
|
|
131
|
+
module Bookshelf
|
|
132
|
+
module TurboStreamActions
|
|
133
|
+
# No target: the JavaScript decides where the toast goes.
|
|
134
|
+
def toast(message, level: "info")
|
|
135
|
+
action(:toast, nil, message:, level:)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# A target: a DOM id string, or a record (turned into an id with dom_id).
|
|
139
|
+
def highlight(target)
|
|
140
|
+
action(:highlight, target)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# A target and content, rendered from a partial.
|
|
144
|
+
def flash_message(target, message)
|
|
145
|
+
action(:flash_message, target, partial: "shared/flash", locals: {message:})
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
Hanami::Turbo::StreamBuilder.include(Bookshelf::TurboStreamActions)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Require it from your app class, so it's loaded before anything renders, whether in the server, the
|
|
154
|
+
console, or tests:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
# config/app.rb
|
|
158
|
+
require "hanami"
|
|
159
|
+
require "hanami/turbo"
|
|
160
|
+
require_relative "turbo_stream_actions"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Keep this file out of `app/` and `lib/<your_app>/`: those are autoloaded, and the module is included
|
|
164
|
+
once, at boot. Like a Rails initializer, changes need a server restart (code reloading won't pick
|
|
165
|
+
them up).
|
|
166
|
+
|
|
167
|
+
### 2. Use them in stream templates
|
|
168
|
+
|
|
169
|
+
```erb
|
|
170
|
+
<%# app/templates/books/create.turbo_stream.erb %>
|
|
171
|
+
<%= turbo_stream.prepend "books", partial: "books/book", locals: {book:} %>
|
|
172
|
+
<%= turbo_stream.toast "Added #{book.title}", level: "success" %>
|
|
173
|
+
<%= turbo_stream.highlight book %>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
This renders:
|
|
177
|
+
|
|
178
|
+
```html
|
|
179
|
+
<turbo-stream action="prepend" target="books"><template>...</template></turbo-stream>
|
|
180
|
+
<turbo-stream action="toast" message="Added Dune" level="success"></turbo-stream>
|
|
181
|
+
<turbo-stream action="highlight" target="book_42"></turbo-stream>
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
For a one-off action, you don't need a module. Use `action` directly, or the
|
|
185
|
+
`turbo_stream_action_tag` view helper:
|
|
186
|
+
|
|
187
|
+
```erb
|
|
188
|
+
<%= turbo_stream.action :scroll_to, book, behavior: "smooth" %>
|
|
189
|
+
<%= turbo_stream_action_tag :console_log, message: "Hello" %>
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### 3. Handle them in JavaScript
|
|
193
|
+
|
|
194
|
+
Register a handler for each action on `StreamActions`. Inside it, `this` is the `<turbo-stream>`
|
|
195
|
+
element:
|
|
196
|
+
|
|
197
|
+
```js
|
|
198
|
+
// app/assets/js/stream_actions.js, imported from app.js
|
|
199
|
+
import { StreamActions } from "@hotwired/turbo";
|
|
200
|
+
|
|
201
|
+
StreamActions.toast = function () {
|
|
202
|
+
const toast = document.createElement("div");
|
|
203
|
+
toast.className = `toast toast-${this.getAttribute("level")}`;
|
|
204
|
+
toast.setAttribute("role", "status");
|
|
205
|
+
toast.textContent = this.getAttribute("message");
|
|
206
|
+
document.getElementById("toasts").append(toast);
|
|
207
|
+
setTimeout(() => toast.remove(), 4000);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
StreamActions.highlight = function () {
|
|
211
|
+
this.targetElements.forEach((element) => element.classList.add("highlight"));
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
StreamActions.flash_message = function () {
|
|
215
|
+
this.targetElements.forEach((element) => element.replaceChildren(this.templateContent));
|
|
216
|
+
};
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Useful properties of the stream element:
|
|
220
|
+
|
|
221
|
+
| | |
|
|
222
|
+
| --- | --- |
|
|
223
|
+
| `this.getAttribute("name")` | an attribute you passed from Ruby |
|
|
224
|
+
| `this.targetElements` | the elements matching `target` (by id) or `targets` (by CSS selector) |
|
|
225
|
+
| `this.templateContent` | a copy of the `<template>` content, if any |
|
|
226
|
+
|
|
227
|
+
Import the handlers in your entry point (`import "./stream_actions.js"`), so they're registered
|
|
228
|
+
before any stream arrives. If no handler is registered for an action, Turbo raises an error in the
|
|
229
|
+
browser console.
|
|
230
|
+
|
|
231
|
+
### The building blocks
|
|
232
|
+
|
|
233
|
+
Your methods (and templates) can use these builder methods:
|
|
234
|
+
|
|
235
|
+
| Method | Renders |
|
|
236
|
+
| --- | --- |
|
|
237
|
+
| `action(name, target, content = nil, **options, &block)` | an action for one target. `target` may be `nil`. |
|
|
238
|
+
| `action_all(name, targets, content = nil, **options, &block)` | an action for every element matching the `targets` CSS selector. |
|
|
239
|
+
| `turbo_stream_action_tag(name, target: nil, targets: nil, template: nil, **attributes)` | the turbo-rails equivalent, with keyword arguments. |
|
|
240
|
+
|
|
241
|
+
Inside your methods, `scope` is the scope of the template being rendered, so you can use its
|
|
242
|
+
helpers, `routes` and locals, just as turbo-rails methods use `@view_context`:
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
def toast_with_link(message, book)
|
|
246
|
+
action(:toast, nil, message:, href: scope.routes.path(:book, id: book.id))
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
How the arguments become HTML:
|
|
251
|
+
|
|
252
|
+
- **Content** goes in a `<template>`, from (in order of precedence) a block, `partial:` with
|
|
253
|
+
`locals:`, or the `content` argument. Strings are HTML-escaped unless marked safe
|
|
254
|
+
(`.html_safe`). Partials are rendered as stream partials if they exist (`_flash.turbo_stream.erb`),
|
|
255
|
+
and otherwise as HTML (`_flash.html.erb`).
|
|
256
|
+
- **A `<template>` is added only when there's content.** Turbo's built-in content actions (`append`,
|
|
257
|
+
`update`, etc.) always get one, since Turbo expects it.
|
|
258
|
+
- **Other keyword arguments become attributes**, with their names used as given. `request_id: 1`
|
|
259
|
+
renders `request_id="1"`, so for a dashed name use a string key (`"request-id": 1`). A `data:` hash
|
|
260
|
+
becomes `data-*` attributes, with keys dasherized and non-string values encoded as JSON.
|
|
261
|
+
Attributes with `nil` values are left out, and `true` renders as `"true"`.
|
|
262
|
+
- **`partial:`, `locals:` and `method:` are reserved**: the first two supply content, and `method:`
|
|
263
|
+
is the attribute Turbo uses for morphing (`method: :morph`).
|
|
264
|
+
|
|
265
|
+
### Testing
|
|
266
|
+
|
|
267
|
+
Custom actions render plain HTML, so request specs can check for them like any other stream:
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
it "shows a toast after creating a book" do
|
|
271
|
+
post "/books", {book: {title: "Dune", author: "Frank Herbert"}},
|
|
272
|
+
{"HTTP_ACCEPT" => "text/vnd.turbo-stream.html, text/html"}
|
|
273
|
+
|
|
274
|
+
expect(last_response.body).to include(
|
|
275
|
+
%(<turbo-stream action="toast" message="Added Dune" level="success"></turbo-stream>)
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
The JavaScript half needs a browser test (for example with Capybara or Playwright).
|
|
281
|
+
|
|
282
|
+
### Porting from turbo-rails
|
|
283
|
+
|
|
284
|
+
| turbo-rails | hanami-turbo |
|
|
285
|
+
| --- | --- |
|
|
286
|
+
| `Turbo::Streams::TagBuilder.prepend(MyActions)` | `Hanami::Turbo::StreamBuilder.include(MyActions)` |
|
|
287
|
+
| Module in `config/initializers/` | Module in `config/`, required from `config/app.rb` |
|
|
288
|
+
| `turbo_stream_action_tag :name, target:, targets:, template:, **attributes` | the same |
|
|
289
|
+
| `action(:name, target, content)` in TagBuilder methods | the same, plus `partial:`/`locals:` and blocks |
|
|
290
|
+
| `@view_context` in TagBuilder methods | `scope`: the template's scope, with its helpers and `routes` |
|
|
291
|
+
| `Turbo.StreamActions.name = function () { ... }` | the same (it's Turbo's JavaScript API) |
|
|
292
|
+
|
|
293
|
+
## Turbo Frames
|
|
294
|
+
|
|
295
|
+
```erb
|
|
296
|
+
<%= turbo_frame_tag "tray" do %>...<% end %>
|
|
297
|
+
<%= turbo_frame_tag book %> <%# id="book_42" %>
|
|
298
|
+
<%= turbo_frame_tag book, :comments, src: routes.path(:book_comments, id: book.id), loading: :lazy %>
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Turbo only uses the matching `<turbo-frame>` from a frame request's response. To skip rendering the
|
|
302
|
+
full layout for these requests:
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
Hanami::Turbo.config.frame_layout = false # no layout
|
|
306
|
+
Hanami::Turbo.config.frame_layout = "frame" # or a lighter layout, e.g. one with just <head>
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
In actions, `req.turbo_frame` returns the requesting frame's id, and `req.turbo_frame?` and
|
|
310
|
+
`req.turbo_stream?` tell you what kind of Turbo request you have.
|
|
311
|
+
|
|
312
|
+
## Buttons, confirmations and non-GET links
|
|
313
|
+
|
|
314
|
+
`button_to` renders a one-button form (with the CSRF token, and `_method` for PATCH, PUT and DELETE),
|
|
315
|
+
like Rails. Attributes go on the button; `form:` sets the form's own attributes, and `params:` adds
|
|
316
|
+
hidden fields.
|
|
317
|
+
|
|
318
|
+
```erb
|
|
319
|
+
<%= button_to "Delete", routes.path(:book, id: book.id),
|
|
320
|
+
method: :delete,
|
|
321
|
+
data: {turbo_confirm: "Delete #{book.title}?"} %>
|
|
322
|
+
|
|
323
|
+
<%= button_to "Archive", routes.path(:archive_book, id: book.id), form: {class: "inline"}, params: {return_to: "/books"} %>
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Turbo asks for confirmation before submitting any form, or form button, with `data-turbo-confirm`.
|
|
327
|
+
You can put it on the button (as above) or the form (`form: {data: {turbo_confirm: "..."}}`), as
|
|
328
|
+
Rails apps do.
|
|
329
|
+
|
|
330
|
+
Links can make non-GET requests through Turbo, with or without confirmation, using `link_to`:
|
|
331
|
+
|
|
332
|
+
```erb
|
|
333
|
+
<%= link_to "Delete", routes.path(:book, id: book.id),
|
|
334
|
+
data: {turbo_method: :delete, turbo_confirm: "Are you sure?"} %>
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Turbo sends these as real `DELETE` (etc.) requests, with the CSRF token from `csrf_meta_tags` in an
|
|
338
|
+
`X-CSRF-Token` header. Prefer `button_to` for anything destructive: unlike links, buttons also work
|
|
339
|
+
without JavaScript.
|
|
340
|
+
|
|
341
|
+
### Custom confirmation dialogs
|
|
342
|
+
|
|
343
|
+
By default Turbo uses the browser's `confirm()`. To show your own dialog, set
|
|
344
|
+
`Turbo.config.forms.confirm` to a function returning a promise of true or false:
|
|
345
|
+
|
|
346
|
+
```erb
|
|
347
|
+
<%# in the layout %>
|
|
348
|
+
<dialog id="confirm-dialog">
|
|
349
|
+
<form method="dialog">
|
|
350
|
+
<p id="confirm-message"></p>
|
|
351
|
+
<button value="cancel">Cancel</button>
|
|
352
|
+
<button value="confirm" data-confirm-button>Confirm</button>
|
|
353
|
+
</form>
|
|
354
|
+
</dialog>
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
```js
|
|
358
|
+
import * as Turbo from "@hotwired/turbo";
|
|
359
|
+
|
|
360
|
+
Turbo.config.forms.confirm = (message, form, submitter) => {
|
|
361
|
+
const dialog = document.getElementById("confirm-dialog");
|
|
362
|
+
dialog.querySelector("#confirm-message").textContent = message;
|
|
363
|
+
dialog.querySelector("[data-confirm-button]").textContent =
|
|
364
|
+
submitter?.dataset.turboConfirmButton || form.dataset.turboConfirmButton || "Confirm";
|
|
365
|
+
dialog.showModal();
|
|
366
|
+
|
|
367
|
+
return new Promise((resolve) => {
|
|
368
|
+
dialog.addEventListener("close", () => resolve(dialog.returnValue === "confirm"), { once: true });
|
|
369
|
+
});
|
|
370
|
+
};
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
The `data-turbo-confirm-button` attribute is a convention from this snippet (not part of Turbo)
|
|
374
|
+
for customising the confirm button's label per button:
|
|
375
|
+
|
|
376
|
+
```erb
|
|
377
|
+
<%= button_to "Delete", path, method: :delete,
|
|
378
|
+
data: {turbo_confirm: "Delete this book?", turbo_confirm_button: "Delete book"} %>
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
(On Turbo 7, use `Turbo.setConfirmMethod(fn)` instead; Turbo 8 deprecates it.)
|
|
382
|
+
|
|
383
|
+
## DOM ids
|
|
384
|
+
|
|
385
|
+
`dom_id` and `dom_class` name records after their class, for use as frame ids and stream targets:
|
|
386
|
+
|
|
387
|
+
```erb
|
|
388
|
+
<%= dom_id(book) %> <%# "book_42" %>
|
|
389
|
+
<%= dom_id(book, :edit) %> <%# "edit_book_42" %>
|
|
390
|
+
<%= dom_id(Book.new) %> <%# "new_book" %>
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Decorated parts also respond to `dom_id` and `dom_class` directly: `<li id="<%= book.dom_id %>">`.
|
|
394
|
+
|
|
395
|
+
## Forms and redirects
|
|
396
|
+
|
|
397
|
+
Turbo expects:
|
|
398
|
+
|
|
399
|
+
- **A redirect after a successful submission.** For non-GET requests, `res.redirect_to` now defaults
|
|
400
|
+
to `303 See Other`. Browsers repeat PATCH and DELETE requests when following a 302, so this is
|
|
401
|
+
required for those; for POST it behaves the same as a 302. An explicit `status:` is always kept.
|
|
402
|
+
To turn this off: `Hanami::Turbo.config.see_other_redirects = false`.
|
|
403
|
+
- **A 4xx status when re-rendering a form with errors.** Turbo ignores a `200` response to a form
|
|
404
|
+
submission, so set `res.status = 422` before rendering.
|
|
405
|
+
|
|
406
|
+
## How it works
|
|
407
|
+
|
|
408
|
+
The gem extends Hanami at public extension points where it can: it prepends `Response#render` and
|
|
409
|
+
`Response#redirect_to`, includes helpers into the standard Hanami view helpers, and adds methods to
|
|
410
|
+
`Request` and `View::Part`. The few calls into hanami-view's private API (checking a template
|
|
411
|
+
exists, and rendering a partial in another format) are kept in `Hanami::Turbo::ViewInternals`.
|
|
412
|
+
|
|
413
|
+
## License
|
|
414
|
+
|
|
415
|
+
MIT
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hanami
|
|
4
|
+
module Turbo
|
|
5
|
+
# Turbo-aware finishing for `Hanami::Action`.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module Action
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Sends error responses as HTML unless they really are Turbo Streams.
|
|
12
|
+
#
|
|
13
|
+
# A Turbo form submission negotiates the stream format before the action runs, so a `halt 404`
|
|
14
|
+
# (or any other error response not rendered from a stream template) would otherwise go out as
|
|
15
|
+
# a stream. Turbo would treat it as empty and do nothing. As HTML, Turbo shows it instead.
|
|
16
|
+
#
|
|
17
|
+
# This prepends `Action#finish`, which is private API, but is also how Hanami's own app
|
|
18
|
+
# integration hooks into actions.
|
|
19
|
+
def finish(request, response, halted)
|
|
20
|
+
super.tap do
|
|
21
|
+
response.format = :html if response.turbo_stream_mismatch?
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Hanami::Action.prepend(Hanami::Turbo::Action)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hanami
|
|
4
|
+
module Turbo
|
|
5
|
+
# Builds DOM ids and classes for records, for use as Turbo Frame ids and Turbo Stream targets.
|
|
6
|
+
#
|
|
7
|
+
# Records may be view parts (their value is used) or any object responding to `#id`. The name
|
|
8
|
+
# comes from the record's class, so a `Bookshelf::Structs::Book` with id 42 has the DOM id
|
|
9
|
+
# `"book_42"`.
|
|
10
|
+
#
|
|
11
|
+
# @api public
|
|
12
|
+
module DomId
|
|
13
|
+
NEW = "new"
|
|
14
|
+
JOIN = "_"
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Returns the DOM id for a record.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# dom_id(book) # => "book_42"
|
|
22
|
+
# dom_id(book, :comments) # => "comments_book_42"
|
|
23
|
+
# dom_id(Book.new) # => "new_book"
|
|
24
|
+
# dom_id(Book.new, :edit) # => "edit_book"
|
|
25
|
+
# dom_id("custom_target") # => "custom_target"
|
|
26
|
+
#
|
|
27
|
+
# @param record [Object, String, Symbol] the record, or a ready-made id
|
|
28
|
+
# @param prefix [String, Symbol, nil] optional prefix
|
|
29
|
+
#
|
|
30
|
+
# @return [String]
|
|
31
|
+
#
|
|
32
|
+
# @api public
|
|
33
|
+
def dom_id(record, prefix = nil, inflector: Turbo.inflector)
|
|
34
|
+
return record.to_s if record.is_a?(String) || record.is_a?(Symbol)
|
|
35
|
+
|
|
36
|
+
value = unwrap(record)
|
|
37
|
+
id = value.id if value.respond_to?(:id)
|
|
38
|
+
|
|
39
|
+
if id.nil?
|
|
40
|
+
dom_class(record, prefix || NEW, inflector:)
|
|
41
|
+
else
|
|
42
|
+
[dom_class(record, prefix, inflector:), id].join(JOIN)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns the DOM class for a record (its singular name, with an optional prefix).
|
|
47
|
+
#
|
|
48
|
+
# @param record [Object] the record
|
|
49
|
+
# @param prefix [String, Symbol, nil] optional prefix
|
|
50
|
+
#
|
|
51
|
+
# @return [String]
|
|
52
|
+
#
|
|
53
|
+
# @api public
|
|
54
|
+
def dom_class(record, prefix = nil, inflector: Turbo.inflector)
|
|
55
|
+
name = record_name(record, inflector)
|
|
56
|
+
prefix ? [prefix, name].join(JOIN) : name
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @api private
|
|
60
|
+
def record_name(record, inflector)
|
|
61
|
+
value = unwrap(record)
|
|
62
|
+
klass = value.is_a?(Class) ? value : value.class
|
|
63
|
+
|
|
64
|
+
# Anonymous classes (and plain hashes or structs from ad hoc data) have no useful class name,
|
|
65
|
+
# so fall back to the part's name, when there is one.
|
|
66
|
+
if klass.name.nil? || [Hash, Struct, Data, Object].include?(klass)
|
|
67
|
+
return inflector.singularize(record._name.to_s) if record.respond_to?(:_name)
|
|
68
|
+
|
|
69
|
+
raise ArgumentError, "cannot infer a DOM id name for #{value.inspect}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
inflector.underscore(inflector.demodulize(klass.name))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @api private
|
|
76
|
+
def unwrap(record)
|
|
77
|
+
record.is_a?(Hanami::View::Part) ? record._value : record
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hanami
|
|
4
|
+
module Turbo
|
|
5
|
+
# View helpers for Turbo, included in the standard Hanami helpers (so available in templates,
|
|
6
|
+
# scopes, and via `helpers` in parts).
|
|
7
|
+
#
|
|
8
|
+
# @api public
|
|
9
|
+
module Helpers
|
|
10
|
+
# Returns a builder for `<turbo-stream>` elements.
|
|
11
|
+
#
|
|
12
|
+
# @return [StreamBuilder]
|
|
13
|
+
#
|
|
14
|
+
# @see StreamBuilder
|
|
15
|
+
#
|
|
16
|
+
# @api public
|
|
17
|
+
def turbo_stream
|
|
18
|
+
StreamBuilder.new(self)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns a `<turbo-stream>` element, like turbo-rails' helper of the same name.
|
|
22
|
+
#
|
|
23
|
+
# @see StreamBuilder#turbo_stream_action_tag
|
|
24
|
+
#
|
|
25
|
+
# @api public
|
|
26
|
+
def turbo_stream_action_tag(...)
|
|
27
|
+
turbo_stream.turbo_stream_action_tag(...)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns a `<turbo-frame>` element.
|
|
31
|
+
#
|
|
32
|
+
# The id may be a string, or a record (with an optional prefix), passed to {#dom_id}.
|
|
33
|
+
#
|
|
34
|
+
# @example
|
|
35
|
+
# turbo_frame_tag "tray" { "..." }
|
|
36
|
+
# turbo_frame_tag book # id="book_42"
|
|
37
|
+
# turbo_frame_tag book, :comments # id="comments_book_42"
|
|
38
|
+
# turbo_frame_tag "details", src: "/books/1/details", loading: :lazy
|
|
39
|
+
#
|
|
40
|
+
# @api public
|
|
41
|
+
def turbo_frame_tag(record_or_id, prefix = nil, src: nil, target: nil, loading: nil, **attributes, &block)
|
|
42
|
+
id =
|
|
43
|
+
if prefix.nil? && (record_or_id.is_a?(String) || record_or_id.is_a?(Symbol))
|
|
44
|
+
record_or_id.to_s
|
|
45
|
+
else
|
|
46
|
+
dom_id(record_or_id, prefix)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
tag.turbo_frame(id:, src:, target:, loading:, **attributes, &block)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns a form containing a single button, for actions that shouldn't be links (anything that
|
|
53
|
+
# changes data). Like Rails' `button_to`.
|
|
54
|
+
#
|
|
55
|
+
# The form includes the CSRF token and, for methods other than GET and POST, a `_method` field.
|
|
56
|
+
# Attributes are given to the button, so `data: {turbo_confirm: "..."}` asks Turbo to confirm
|
|
57
|
+
# before submitting. Use `form:` for the form's own attributes.
|
|
58
|
+
#
|
|
59
|
+
# @example
|
|
60
|
+
# button_to "Delete", routes.path(:book, id: book.id), method: :delete,
|
|
61
|
+
# data: {turbo_confirm: "Delete #{book.title}?"}
|
|
62
|
+
#
|
|
63
|
+
# button_to routes.path(:archive_book, id: book.id), form: {class: "inline"} do
|
|
64
|
+
# "<span>Archive</span>".html_safe
|
|
65
|
+
# end
|
|
66
|
+
#
|
|
67
|
+
# @param content [String, nil] button content (omit when giving a block)
|
|
68
|
+
# @param url [String] the form's action
|
|
69
|
+
# @param method [Symbol, String] HTTP method (default: POST)
|
|
70
|
+
# @param form [Hash] attributes for the form (default class: `"button_to"`)
|
|
71
|
+
# @param params [Hash] extra hidden fields to submit
|
|
72
|
+
# @param attributes [Hash] attributes for the button
|
|
73
|
+
#
|
|
74
|
+
# @return [String] the form
|
|
75
|
+
#
|
|
76
|
+
# @api public
|
|
77
|
+
def button_to(content = nil, url = nil, method: :post, form: {}, params: {}, **attributes, &block)
|
|
78
|
+
if block
|
|
79
|
+
raise ArgumentError, "give either content or a block, not both" if url
|
|
80
|
+
|
|
81
|
+
url = content
|
|
82
|
+
content = nil
|
|
83
|
+
end
|
|
84
|
+
raise ArgumentError, "a URL is required" unless url
|
|
85
|
+
|
|
86
|
+
form_attributes = {class: "button_to", **form}
|
|
87
|
+
|
|
88
|
+
# `values:` and `params:` are form_for's sources for field values; this form has no fields.
|
|
89
|
+
form_for(url, method:, values: {}, params: {}, **form_attributes) do |f|
|
|
90
|
+
hidden_fields = params.map { |name, value|
|
|
91
|
+
tag.input(type: "hidden", name: name.to_s, value: value.to_s)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
button =
|
|
95
|
+
if block
|
|
96
|
+
f.button(type: "submit", **attributes, &block)
|
|
97
|
+
else
|
|
98
|
+
f.button(content, type: "submit", **attributes)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
(hidden_fields.join + button).html_safe
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @see DomId.dom_id
|
|
106
|
+
#
|
|
107
|
+
# @api public
|
|
108
|
+
def dom_id(record, prefix = nil)
|
|
109
|
+
DomId.dom_id(record, prefix, inflector: _turbo_inflector)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @see DomId.dom_class
|
|
113
|
+
#
|
|
114
|
+
# @api public
|
|
115
|
+
def dom_class(record, prefix = nil)
|
|
116
|
+
DomId.dom_class(record, prefix, inflector: _turbo_inflector)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def _turbo_inflector
|
|
122
|
+
respond_to?(:_inflector, true) ? _inflector : Turbo.inflector
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
Hanami::View::Scope.include(Hanami::Turbo::Helpers)
|
|
129
|
+
Hanami::Extensions::View::StandardHelpers.include(Hanami::Turbo::Helpers)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hanami
|
|
4
|
+
module Turbo
|
|
5
|
+
# DOM id methods for view parts.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# <article id="<%= book.dom_id %>">
|
|
9
|
+
#
|
|
10
|
+
# @api public
|
|
11
|
+
module Part
|
|
12
|
+
# @see DomId.dom_id
|
|
13
|
+
#
|
|
14
|
+
# @api public
|
|
15
|
+
def dom_id(prefix = nil)
|
|
16
|
+
DomId.dom_id(self, prefix, inflector: _rendering.inflector)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @see DomId.dom_class
|
|
20
|
+
#
|
|
21
|
+
# @api public
|
|
22
|
+
def dom_class(prefix = nil)
|
|
23
|
+
DomId.dom_class(self, prefix, inflector: _rendering.inflector)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
Hanami::View::Part.include(Hanami::Turbo::Part)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hanami
|
|
4
|
+
module Turbo
|
|
5
|
+
# Turbo-specific methods for `Hanami::Action::Request`.
|
|
6
|
+
#
|
|
7
|
+
# @api public
|
|
8
|
+
module Request
|
|
9
|
+
TURBO_FRAME_HEADER = "HTTP_TURBO_FRAME"
|
|
10
|
+
ACCEPT_HEADER = "HTTP_ACCEPT"
|
|
11
|
+
|
|
12
|
+
# Returns the id of the Turbo Frame that made this request, if any.
|
|
13
|
+
#
|
|
14
|
+
# @return [String, nil]
|
|
15
|
+
#
|
|
16
|
+
# @api public
|
|
17
|
+
def turbo_frame
|
|
18
|
+
get_header(TURBO_FRAME_HEADER)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Returns true if this request was made by a Turbo Frame.
|
|
22
|
+
#
|
|
23
|
+
# @return [Boolean]
|
|
24
|
+
#
|
|
25
|
+
# @api public
|
|
26
|
+
def turbo_frame?
|
|
27
|
+
!turbo_frame.nil?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns true if the request explicitly accepts Turbo Stream responses.
|
|
31
|
+
#
|
|
32
|
+
# Turbo adds the stream media type to the `Accept` header of form submissions. Unlike
|
|
33
|
+
# `#accept?`, this does not match wildcard `Accept` headers.
|
|
34
|
+
#
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
#
|
|
37
|
+
# @api public
|
|
38
|
+
def turbo_stream?
|
|
39
|
+
get_header(ACCEPT_HEADER).to_s.include?(MEDIA_TYPE)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Hanami::Action::Request.include(Hanami::Turbo::Request)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hanami
|
|
4
|
+
module Turbo
|
|
5
|
+
# Turbo-aware rendering and redirects for `Hanami::Action::Response`.
|
|
6
|
+
#
|
|
7
|
+
# @api public
|
|
8
|
+
module Response
|
|
9
|
+
# Renders the view, choosing its format and layout for Turbo requests.
|
|
10
|
+
#
|
|
11
|
+
# - For Turbo Stream responses, renders the view's `.turbo_stream` template without a layout.
|
|
12
|
+
# If the view has no such template, switches the response to HTML and renders that instead,
|
|
13
|
+
# so Turbo never receives HTML labelled as a stream.
|
|
14
|
+
# - For Turbo Frame requests, renders with {Turbo.config.frame_layout}, when one is set.
|
|
15
|
+
#
|
|
16
|
+
# Any `format:` or `layout:` given explicitly takes precedence.
|
|
17
|
+
#
|
|
18
|
+
# @api public
|
|
19
|
+
def render(view, **input)
|
|
20
|
+
options = _turbo_render_options(view, input)
|
|
21
|
+
@_turbo_stream_rendered = (input[:format] || options[:format]) == FORMAT
|
|
22
|
+
|
|
23
|
+
super(view, **options, **input)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Redirects to the given URL, defaulting to `303 See Other` for non-GET requests.
|
|
27
|
+
#
|
|
28
|
+
# @see Turbo.config.see_other_redirects
|
|
29
|
+
#
|
|
30
|
+
# @api public
|
|
31
|
+
def redirect_to(url, status: nil)
|
|
32
|
+
status ||= SEE_OTHER if _turbo_see_other?
|
|
33
|
+
|
|
34
|
+
status ? super(url, status:) : super(url)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns true if the response is labelled a Turbo Stream but its body is not one.
|
|
38
|
+
#
|
|
39
|
+
# This happens for error responses that weren't rendered from a stream template, such as
|
|
40
|
+
# `halt 404`. Turbo would treat these as empty streams and silently do nothing.
|
|
41
|
+
#
|
|
42
|
+
# @api private
|
|
43
|
+
def turbo_stream_mismatch?
|
|
44
|
+
return false unless format == FORMAT && status >= 400
|
|
45
|
+
return false if @_turbo_stream_rendered
|
|
46
|
+
|
|
47
|
+
# Only inspect buffered bodies; leave streamed ones (e.g. from `send_file`) alone.
|
|
48
|
+
return false unless body.is_a?(Array)
|
|
49
|
+
|
|
50
|
+
!body.first.to_s.lstrip.start_with?("<turbo-stream")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
SEE_OTHER = 303
|
|
54
|
+
private_constant :SEE_OTHER
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def _turbo_render_options(view, input)
|
|
59
|
+
options = {}
|
|
60
|
+
|
|
61
|
+
if !input.key?(:format) && format == FORMAT
|
|
62
|
+
return {format: FORMAT, layout: false} if ViewInternals.template?(view, FORMAT)
|
|
63
|
+
|
|
64
|
+
# No stream template, so render HTML. Turbo handles HTML responses to form submissions
|
|
65
|
+
# (including those made inside frames) like any other page.
|
|
66
|
+
self.format = :html
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
if request&.turbo_frame? && !Turbo.config.frame_layout.nil? && input[:format] != FORMAT
|
|
70
|
+
options[:layout] = Turbo.config.frame_layout
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
options
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def _turbo_see_other?
|
|
77
|
+
Turbo.config.see_other_redirects && request && !request.get? && !request.head?
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Hanami::Action::Response.prepend(Hanami::Turbo::Response)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hanami
|
|
4
|
+
module Turbo
|
|
5
|
+
# Builds `<turbo-stream>` elements. Returned by the `turbo_stream` view helper.
|
|
6
|
+
#
|
|
7
|
+
# Each action takes a target (a DOM id string, or a record passed through {DomId.dom_id}), and
|
|
8
|
+
# its content as a string, a block, or a partial to render.
|
|
9
|
+
#
|
|
10
|
+
# Partials are rendered in the current format when such a partial exists (e.g.
|
|
11
|
+
# `_book.turbo_stream.erb`), and otherwise as HTML, so the same `_book.html.erb` partial can be
|
|
12
|
+
# used for full page renders and for streams.
|
|
13
|
+
#
|
|
14
|
+
# Add your own stream actions by including a module, as turbo-rails apps do with
|
|
15
|
+
# `Turbo::Streams::TagBuilder`. Methods can call {#action}, {#action_all}, or
|
|
16
|
+
# {#turbo_stream_action_tag}, so modules written for turbo-rails work unchanged:
|
|
17
|
+
#
|
|
18
|
+
# module MyApp::TurboStreamActions
|
|
19
|
+
# def toast(message, level: "info")
|
|
20
|
+
# action(:toast, nil, message:, level:)
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# Hanami::Turbo::StreamBuilder.include(MyApp::TurboStreamActions)
|
|
25
|
+
#
|
|
26
|
+
# Then handle the action in JavaScript via `Turbo.StreamActions`.
|
|
27
|
+
#
|
|
28
|
+
# @example In a `.turbo_stream.erb` template
|
|
29
|
+
# <%= turbo_stream.append "books", partial: "books/book", locals: {book:} %>
|
|
30
|
+
# <%= turbo_stream.replace book do %>
|
|
31
|
+
# <p><%= book.title %></p>
|
|
32
|
+
# <% end %>
|
|
33
|
+
# <%= turbo_stream.remove book %>
|
|
34
|
+
#
|
|
35
|
+
# @api public
|
|
36
|
+
class StreamBuilder
|
|
37
|
+
# Actions that take content.
|
|
38
|
+
CONTENT_ACTIONS = %i[append prepend replace update before after].freeze
|
|
39
|
+
|
|
40
|
+
# @api private
|
|
41
|
+
def initialize(scope)
|
|
42
|
+
@scope = scope
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
CONTENT_ACTIONS.each do |name|
|
|
46
|
+
# Targets one element by DOM id.
|
|
47
|
+
define_method(name) do |target, content = nil, **options, &block|
|
|
48
|
+
action(name, target, content, **options, &block)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Targets all elements matching a CSS selector.
|
|
52
|
+
define_method(:"#{name}_all") do |targets, content = nil, **options, &block|
|
|
53
|
+
action_all(name, targets, content, **options, &block)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Removes the target element.
|
|
58
|
+
#
|
|
59
|
+
# @api public
|
|
60
|
+
def remove(target)
|
|
61
|
+
action(:remove, target)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Removes all elements matching the CSS selector.
|
|
65
|
+
#
|
|
66
|
+
# @api public
|
|
67
|
+
def remove_all(targets)
|
|
68
|
+
action_all(:remove, targets)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Asks the page to refresh itself (Turbo 8 page refreshes).
|
|
72
|
+
#
|
|
73
|
+
# @param request_id [String, nil] identifies the request, so the page that made it can ignore
|
|
74
|
+
# the refresh
|
|
75
|
+
#
|
|
76
|
+
# @api public
|
|
77
|
+
def refresh(request_id: nil, **attributes)
|
|
78
|
+
stream_tag(action: :refresh, "request-id": request_id, **attributes)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Builds a stream element for any action, including custom ones.
|
|
82
|
+
#
|
|
83
|
+
# Any other keyword arguments become attributes of the `<turbo-stream>` element (except
|
|
84
|
+
# `partial:`, `locals:` and `method:`, which are used as described below). A `<template>` is
|
|
85
|
+
# included when there is content, and always for Turbo's built-in content actions.
|
|
86
|
+
#
|
|
87
|
+
# @param name [Symbol, String] the stream action
|
|
88
|
+
# @param target [Object, nil] DOM id string, a record, or nil for actions without a target
|
|
89
|
+
# @param content [String, nil] content for the element's `<template>`
|
|
90
|
+
# @param partial [String, nil] partial to render as the content
|
|
91
|
+
# @param locals [Hash] locals for the partial
|
|
92
|
+
# @param method [Symbol, nil] `:morph` to morph rather than replace (for `replace` and `update`)
|
|
93
|
+
#
|
|
94
|
+
# @api public
|
|
95
|
+
def action(name, target, content = nil, **options, &block)
|
|
96
|
+
target_attributes = target.nil? ? {} : {target: DomId.dom_id(target, inflector: inflector)}
|
|
97
|
+
stream(name, target_attributes, content, **options, &block)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Builds a stream element for any action, targeting all elements matching a CSS selector.
|
|
101
|
+
#
|
|
102
|
+
# @api public
|
|
103
|
+
def action_all(name, targets, content = nil, **options, &block)
|
|
104
|
+
stream(name, {targets: targets}, content, **options, &block)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Builds a stream element, with the same signature as turbo-rails' `turbo_stream_action_tag`.
|
|
108
|
+
#
|
|
109
|
+
# @example
|
|
110
|
+
# turbo_stream_action_tag :console_log, message: "Hello"
|
|
111
|
+
# turbo_stream_action_tag :notify, target: book, template: "<b>Saved</b>".html_safe
|
|
112
|
+
#
|
|
113
|
+
# @param name [Symbol, String] the stream action
|
|
114
|
+
# @param target [Object, nil] DOM id string or record
|
|
115
|
+
# @param targets [String, nil] CSS selector, instead of a target
|
|
116
|
+
# @param template [String, nil] content for the element's `<template>`
|
|
117
|
+
# @param attributes [Hash] other attributes for the `<turbo-stream>` element
|
|
118
|
+
#
|
|
119
|
+
# @api public
|
|
120
|
+
def turbo_stream_action_tag(name, target: nil, targets: nil, template: nil, **attributes)
|
|
121
|
+
if targets
|
|
122
|
+
action_all(name, targets, template, **attributes)
|
|
123
|
+
else
|
|
124
|
+
action(name, target, template, **attributes)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
# The scope of the template being rendered. Custom action methods can use it for helpers,
|
|
131
|
+
# `routes` and locals (the equivalent of `@view_context` in turbo-rails).
|
|
132
|
+
#
|
|
133
|
+
# @api public
|
|
134
|
+
attr_reader :scope
|
|
135
|
+
|
|
136
|
+
def stream(name, target_attributes, content = nil, partial: nil, locals: {}, method: nil, **attributes, &block)
|
|
137
|
+
content = render_partial(partial, locals) if partial
|
|
138
|
+
content = block.call if block
|
|
139
|
+
|
|
140
|
+
if name.to_sym != :remove && (!content.nil? || CONTENT_ACTIONS.include?(name.to_sym))
|
|
141
|
+
template = scope.tag.template(content.to_s)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
stream_tag(template, action: name, **target_attributes, method:, **attributes)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def render_partial(name, locals)
|
|
148
|
+
rendering = scope._rendering
|
|
149
|
+
format = rendering.format
|
|
150
|
+
|
|
151
|
+
if format == :html || ViewInternals.partial?(rendering, name, format)
|
|
152
|
+
return scope.render(name, **locals)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
html_rendering = ViewInternals.with_format(rendering, :html, scope._context)
|
|
156
|
+
partial_locals = locals.empty? ? scope._locals : locals
|
|
157
|
+
html_rendering.partial(name, scope.class.new(locals: partial_locals, rendering: html_rendering))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def stream_tag(content = nil, **attributes)
|
|
161
|
+
scope.tag.turbo_stream(content.to_s.html_safe, **attributes.compact)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def inflector
|
|
165
|
+
scope.respond_to?(:_inflector, true) ? scope.send(:_inflector) : Turbo.inflector
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hanami
|
|
4
|
+
module Turbo
|
|
5
|
+
# The only place hanami-turbo reaches into hanami-view's private API.
|
|
6
|
+
#
|
|
7
|
+
# hanami-view has no public way to ask whether a template exists, or to render with a different
|
|
8
|
+
# format partway through a render. Keeping these calls together makes them easy to audit, and
|
|
9
|
+
# to replace if hanami-view gains public equivalents.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
module ViewInternals
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Returns true if the view has a template for the format.
|
|
16
|
+
def template?(view, format)
|
|
17
|
+
!view.rendering(format: format).renderer.send(:lookup, view.config.template, format).nil?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns true if the rendering can find the partial in the format.
|
|
21
|
+
def partial?(rendering, name, format)
|
|
22
|
+
renderer = rendering.renderer
|
|
23
|
+
!renderer.send(:lookup, renderer.send(:name_for_partial, name), format).nil?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns a copy of the rendering using a different format, sharing its config and context.
|
|
27
|
+
def with_format(rendering, format, context)
|
|
28
|
+
Hanami::View::Rendering.new(
|
|
29
|
+
config_data: rendering.renderer.send(:config_data),
|
|
30
|
+
format: format,
|
|
31
|
+
context: context
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/hanami/turbo.rb
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/configurable"
|
|
4
|
+
require "dry/inflector"
|
|
5
|
+
require "hanami"
|
|
6
|
+
require "hanami/action"
|
|
7
|
+
require "hanami/view"
|
|
8
|
+
|
|
9
|
+
require_relative "turbo/version"
|
|
10
|
+
|
|
11
|
+
module Hanami
|
|
12
|
+
# Hotwire Turbo support for Hanami apps.
|
|
13
|
+
#
|
|
14
|
+
# Requiring this file extends `Hanami::Action::Request`, `Hanami::Action::Response`,
|
|
15
|
+
# `Hanami::View::Part` and the standard Hanami view helpers. Call {.install} from your app class to
|
|
16
|
+
# register the Turbo Stream format.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# # config/app.rb
|
|
20
|
+
# require "hanami"
|
|
21
|
+
# require "hanami/turbo"
|
|
22
|
+
#
|
|
23
|
+
# module Bookshelf
|
|
24
|
+
# class App < Hanami::App
|
|
25
|
+
# Hanami::Turbo.install(config)
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# @api public
|
|
30
|
+
module Turbo
|
|
31
|
+
extend Dry::Configurable
|
|
32
|
+
|
|
33
|
+
# The format name for Turbo Stream responses.
|
|
34
|
+
FORMAT = :turbo_stream
|
|
35
|
+
|
|
36
|
+
# The media type Turbo sends in `Accept` headers and expects on stream responses.
|
|
37
|
+
MEDIA_TYPE = "text/vnd.turbo-stream.html"
|
|
38
|
+
|
|
39
|
+
# @!attribute [rw] see_other_redirects
|
|
40
|
+
# Whether `res.redirect_to` defaults to `303 See Other` for non-GET requests. Turbo requires a
|
|
41
|
+
# 303 after PATCH, PUT and DELETE form submissions, since browsers repeat the original method
|
|
42
|
+
# when following a 302. An explicitly given `status:` is always respected.
|
|
43
|
+
#
|
|
44
|
+
# Defaults to true.
|
|
45
|
+
setting :see_other_redirects, default: true
|
|
46
|
+
|
|
47
|
+
# @!attribute [rw] frame_layout
|
|
48
|
+
# The layout to use when rendering HTML for a request made by a Turbo Frame (one with a
|
|
49
|
+
# `Turbo-Frame` header). Turbo only uses the matching `<turbo-frame>` from these responses, so
|
|
50
|
+
# rendering the full layout is wasted work.
|
|
51
|
+
#
|
|
52
|
+
# `nil` (the default) leaves the view's layout unchanged. `false` renders no layout. A string
|
|
53
|
+
# names a layout within the view's `layouts_dir`.
|
|
54
|
+
setting :frame_layout, default: nil
|
|
55
|
+
|
|
56
|
+
# Registers the `:turbo_stream` format with the given app config.
|
|
57
|
+
#
|
|
58
|
+
# `:turbo_stream` is added to the app's accepted formats, so that Turbo's `Accept` header
|
|
59
|
+
# negotiates a stream response. Apps that accept any format (the default) also get the `:all`
|
|
60
|
+
# format, which keeps them accepting any format, so installing Turbo never causes new 406
|
|
61
|
+
# responses.
|
|
62
|
+
#
|
|
63
|
+
# @param config [Hanami::Config] the app's config
|
|
64
|
+
#
|
|
65
|
+
# @return [void]
|
|
66
|
+
#
|
|
67
|
+
# @api public
|
|
68
|
+
def self.install(config)
|
|
69
|
+
formats = config.actions.formats
|
|
70
|
+
formats.register(FORMAT, MEDIA_TYPE)
|
|
71
|
+
|
|
72
|
+
# Assign `accepted` directly rather than using `accept`, which would make the first format the
|
|
73
|
+
# default response format.
|
|
74
|
+
formats.accepted = (formats.accepted.empty? ? [:all] : formats.accepted) | [FORMAT]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @api private
|
|
78
|
+
def self.inflector
|
|
79
|
+
@inflector ||= Dry::Inflector.new
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
require_relative "turbo/view_internals"
|
|
85
|
+
require_relative "turbo/dom_id"
|
|
86
|
+
require_relative "turbo/request"
|
|
87
|
+
require_relative "turbo/response"
|
|
88
|
+
require_relative "turbo/action"
|
|
89
|
+
require_relative "turbo/stream_builder"
|
|
90
|
+
require_relative "turbo/helpers"
|
|
91
|
+
require_relative "turbo/part"
|
metadata
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: hanami-turbo
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Andrea Fomera
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: hanami
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '2.3'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.3'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: hanami-action
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.3'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.3'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: hanami-view
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.3'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '2.3'
|
|
54
|
+
description: Turbo Stream rendering, Turbo Frame helpers, and Turbo-friendly redirects
|
|
55
|
+
for Hanami
|
|
56
|
+
email:
|
|
57
|
+
- afomera@hey.com
|
|
58
|
+
executables: []
|
|
59
|
+
extensions: []
|
|
60
|
+
extra_rdoc_files: []
|
|
61
|
+
files:
|
|
62
|
+
- CHANGELOG.md
|
|
63
|
+
- LICENSE.md
|
|
64
|
+
- README.md
|
|
65
|
+
- lib/hanami/turbo.rb
|
|
66
|
+
- lib/hanami/turbo/action.rb
|
|
67
|
+
- lib/hanami/turbo/dom_id.rb
|
|
68
|
+
- lib/hanami/turbo/helpers.rb
|
|
69
|
+
- lib/hanami/turbo/part.rb
|
|
70
|
+
- lib/hanami/turbo/request.rb
|
|
71
|
+
- lib/hanami/turbo/response.rb
|
|
72
|
+
- lib/hanami/turbo/stream_builder.rb
|
|
73
|
+
- lib/hanami/turbo/version.rb
|
|
74
|
+
- lib/hanami/turbo/view_internals.rb
|
|
75
|
+
homepage: https://hanamirb.org
|
|
76
|
+
licenses:
|
|
77
|
+
- MIT
|
|
78
|
+
metadata: {}
|
|
79
|
+
rdoc_options: []
|
|
80
|
+
require_paths:
|
|
81
|
+
- lib
|
|
82
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - ">="
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: '3.3'
|
|
87
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
88
|
+
requirements:
|
|
89
|
+
- - ">="
|
|
90
|
+
- !ruby/object:Gem::Version
|
|
91
|
+
version: '0'
|
|
92
|
+
requirements: []
|
|
93
|
+
rubygems_version: 4.0.20
|
|
94
|
+
specification_version: 4
|
|
95
|
+
summary: Hotwire Turbo support for Hanami apps
|
|
96
|
+
test_files: []
|