weft 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +134 -28
- data/README.md +46 -23
- data/docs/app-patterns.md +8 -7
- data/docs/arbre.md +49 -18
- data/docs/configuration.md +42 -35
- data/docs/dsl.md +356 -105
- data/docs/error-handling.md +64 -24
- data/docs/examples/active-search.md +10 -10
- data/docs/examples/browser-dialogs.md +10 -10
- data/docs/examples/bulk-update.md +11 -11
- data/docs/examples/click-to-edit.md +14 -14
- data/docs/examples/click-to-load.md +8 -8
- data/docs/examples/delete-row.md +17 -19
- data/docs/examples/edit-row.md +17 -14
- data/docs/examples/file-upload.md +5 -5
- data/docs/examples/infinite-scroll.md +8 -8
- data/docs/examples/inline-expansion.md +8 -8
- data/docs/examples/inline-validation.md +17 -17
- data/docs/examples/lazy-loading.md +8 -8
- data/docs/examples/live-ticker.md +1 -1
- data/docs/examples/modal-dialog.md +3 -3
- data/docs/examples/progress-bar.md +1 -1
- data/docs/examples/reset-user-input.md +7 -7
- data/docs/examples/tabs.md +4 -4
- data/docs/examples/tooltip.md +8 -8
- data/docs/examples/updating-other-content.md +9 -9
- data/docs/examples/value-select.md +11 -11
- data/docs/params.md +112 -0
- data/docs/routing.md +13 -13
- data/docs/tutorial.md +46 -48
- data/lib/weft/action.rb +4 -2
- data/lib/weft/autoloading.rb +69 -0
- data/lib/weft/component.rb +97 -31
- data/lib/weft/configuration.rb +37 -5
- data/lib/weft/context/expansion.rb +184 -0
- data/lib/weft/context/interception.rb +22 -2
- data/lib/weft/context/modifiers.rb +78 -0
- data/lib/weft/context/traversal.rb +80 -0
- data/lib/weft/context/wiring.rb +85 -0
- data/lib/weft/context.rb +70 -164
- data/lib/weft/defaults/error_component.rb +57 -21
- data/lib/weft/defaults/error_page.rb +12 -10
- data/lib/weft/defaults/not_found_component.rb +14 -12
- data/lib/weft/defaults/not_found_page.rb +9 -8
- data/lib/weft/dsl/actions.rb +9 -9
- data/lib/weft/dsl/inclusions.rb +48 -11
- data/lib/weft/dsl/params.rb +265 -0
- data/lib/weft/dsl/recoveries.rb +36 -6
- data/lib/weft/dsl/sandbox.rb +26 -0
- data/lib/weft/dsl/triggers.rb +28 -8
- data/lib/weft/dsl/updates.rb +31 -8
- data/lib/weft/error.rb +12 -1
- data/lib/weft/page/assets.rb +222 -0
- data/lib/weft/page/head.rb +87 -0
- data/lib/weft/page.rb +55 -239
- data/lib/weft/params/assembly.rb +170 -0
- data/lib/weft/params.rb +138 -0
- data/lib/weft/presets.rb +96 -0
- data/lib/weft/redirect.rb +7 -7
- data/lib/weft/registry/eligibility.rb +5 -19
- data/lib/weft/registry.rb +58 -18
- data/lib/weft/resolver.rb +48 -20
- data/lib/weft/router/actions.rb +106 -22
- data/lib/weft/router/errors.rb +223 -83
- data/lib/weft/router/oob_includes.rb +202 -17
- data/lib/weft/router/streaming.rb +86 -20
- data/lib/weft/router.rb +33 -25
- data/lib/weft/version.rb +1 -1
- data/lib/weft.rb +37 -24
- metadata +32 -8
- data/lib/weft/attributes.rb +0 -65
- data/lib/weft/dsl/attributes.rb +0 -43
- data/lib/weft/shorthands.rb +0 -57
data/docs/error-handling.md
CHANGED
|
@@ -8,8 +8,9 @@ Two layers cooperate to make this work. On the server, the Router catches errors
|
|
|
8
8
|
|
|
9
9
|
- [The error classes](#the-error-classes)
|
|
10
10
|
- [The `recovers` chain](#the-recovers-chain) — matching, targets, blocks, and [the built-in edges](#the-built-in-edges)
|
|
11
|
-
- [What happens when something raises](#what-happens-when-something-raises)
|
|
12
|
-
- [
|
|
11
|
+
- [What happens when something raises](#what-happens-when-something-raises) — including [where failure-prone work belongs](#where-the-work-that-can-fail-belongs) and [when a companion fails](#when-a-companion-fails)
|
|
12
|
+
- [Error handling on live streams](#error-handling-on-live-streams)
|
|
13
|
+
- [Auto-injected recovery params](#auto-injected-recovery-params)
|
|
13
14
|
- [Presentation settings](#presentation-settings)
|
|
14
15
|
|
|
15
16
|
## The error classes
|
|
@@ -26,7 +27,7 @@ Weft ships a small semantic hierarchy rooted at `Weft::Error`:
|
|
|
26
27
|
| `Weft::Unprocessable` | 422 | The request was understood but can't be acted on — validation failures, mostly. |
|
|
27
28
|
| `Weft::InternalError` | 500 | An explicit "we broke" signal. |
|
|
28
29
|
|
|
29
|
-
Raise these from your `build` methods and action callables to communicate outcomes with the right status semantics: `raise Weft::NotFound` when a record lookup comes up empty, `raise Weft::Unprocessable` when validation fails.
|
|
30
|
+
Raise these from your `build` methods and action callables to communicate outcomes with the right status semantics: `raise Weft::NotFound` when a record lookup comes up empty, `raise Weft::Unprocessable` when validation fails. They're a convenience, not a requirement — your code can keep raising its own vocabulary (`ActiveRecord::RecordNotFound`, a domain error) and let a `recovers` edge [declare what it means](#the-recovers-chain) with `status:`. An error that's neither a `Weft::HTTPError` nor mapped by such an edge is treated as status 500.
|
|
30
31
|
|
|
31
32
|
A separate branch of the family reports *your* mistakes to you, raised at definition or configuration time rather than during request handling: `Weft::InvalidConfiguration` (a bad value inside `Weft.configure`), `Weft::InvalidDefinition` (a bad class-body declaration, including route collisions), and `Weft::InvalidUsage` (a bad call at render time). These are meant to fail loudly during development, not to be recovery targets.
|
|
32
33
|
|
|
@@ -36,10 +37,11 @@ Components and pages declare how they handle errors with `recovers`:
|
|
|
36
37
|
|
|
37
38
|
```ruby
|
|
38
39
|
class OrderEditor < Weft::Component
|
|
39
|
-
recovers from: Weft::Unprocessable do |
|
|
40
|
+
recovers from: Weft::Unprocessable do |params, error|
|
|
40
41
|
{ error_message: error.message }
|
|
41
42
|
end
|
|
42
43
|
recovers from: Weft::Unauthorized, with: LoginPage
|
|
44
|
+
recovers from: ActiveRecord::RecordNotFound, with: NotFoundCard, status: 404
|
|
43
45
|
end
|
|
44
46
|
```
|
|
45
47
|
|
|
@@ -53,9 +55,13 @@ Each declaration is an edge: *when this kind of error escapes me, render that in
|
|
|
53
55
|
- a **Range** — matches statuses in the range (`from: 500..599`);
|
|
54
56
|
- an **Array** of any of the above — matches if any element does.
|
|
55
57
|
|
|
56
|
-
**`with:`** names the recovery target — what renders in place of the failure. It accepts a component or page class, or a symbol naming a [configuration knob](configuration.md#the-four-fallback-targets) (`with: :error_component`), resolved at error-handling time so reconfiguration propagates. Omitted, it defaults to the declaring class itself — "on this error, re-render me" — which pairs naturally with a block that adjusts
|
|
58
|
+
**`with:`** names the recovery target — what renders in place of the failure. It accepts a component or page class, or a symbol naming a [configuration knob](configuration.md#the-four-fallback-targets) (`with: :error_component`), resolved at error-handling time so reconfiguration propagates. Omitted, it defaults to the declaring class itself — "on this error, re-render me" — which pairs naturally with a block that adjusts params.
|
|
57
59
|
|
|
58
|
-
|
|
60
|
+
**`status:`** declares what a matched error *means* on the wire. Weft's own error classes carry their status with them, but your app's errors don't need translating into Weft's — recover from them directly and let the edge supply the semantics, as the `ActiveRecord::RecordNotFound` edge above does: the response status and the auto-injected `:status_code` param both follow it, so the branded rendering is a genuine 404. Without it, a recovered non-`Weft::HTTPError` reports as 500. Only error statuses (400–599) are assignable; an invalid value raises `Weft::InvalidUsage` at declaration time.
|
|
61
|
+
|
|
62
|
+
**The block**, if given, receives `(params, error)` — plus the exception — and returns a hash merged into the params the recovery target renders with (returned keys win). It's for *carrying information onto the error rendering*, like the validation messages above; it never returns HTML.
|
|
63
|
+
|
|
64
|
+
The `params` it gets are **the state the request had reached when it broke**, with every door open: wire values, derivations, defines. If the failure was in an action callable, that's what the callable was reading; if the callable succeeded and the *render* failed, it's that plus whatever the callable returned; and a companion's recovery block sees what its own `build` saw, its inclusion block's delta included. A derivation the failed code had already forced is still forced, so reading it in the recovery block costs nothing — and one it hadn't will run now, which is worth remembering if it's the kind of lookup that can fail twice.
|
|
59
65
|
|
|
60
66
|
Edges are consulted in a defined order: a class's own declarations first (in declaration order), then its ancestors' — so subclass declarations beat inherited ones, and within a class, first match wins. Put more-specific edges before catch-alls.
|
|
61
67
|
|
|
@@ -77,27 +83,61 @@ The symbols resolve through `Weft.configuration`, so [reassigning those knobs](c
|
|
|
77
83
|
|
|
78
84
|
## What happens when something raises
|
|
79
85
|
|
|
80
|
-
**In component context** — a fragment render
|
|
86
|
+
**In component context** — a fragment render or an action — the Router walks the failing component's chain and renders the matched target as a fragment, with the response status taken from the exception (`Weft::HTTPError#status`, else 500). On the client, the fragment swaps in where the component's response would have gone, so the error appears exactly where the problem is. If the matched target is a *page* class, the recovery becomes a redirect to that page instead (`HX-Redirect` for htmx requests, 302 otherwise) — the `with: LoginPage` pattern above.
|
|
81
87
|
|
|
82
|
-
One wrinkle worth knowing: for actions with a destructive swap (`dismisses`, or any `performs` with `swap: :delete`), a successful response removes the element — which would make an error invisible. Weft overrides the swap on error responses (via `HX-Reswap`) so the error rendering replaces the component instead of vanishing with it.
|
|
88
|
+
One wrinkle worth knowing: for actions with a destructive swap (`dismisses`, or any `performs` with `swap: :delete`), a successful response removes the element — which would make an error invisible. Weft overrides the swap on error responses (via `HX-Reswap`) so the error rendering replaces the component instead of vanishing with it. The replacement also arrives correctly shaped: recovery fragments adopt the failing component's wrapper tag (via `:component_tag`, below), so a failed delete on a table row produces an error *row* the table can legally contain.
|
|
83
89
|
|
|
84
90
|
**In page context** — a full-document render, or a request no route matched — the Router walks the page's chain (for routing misses, the base `Weft::Page` chain, which lands on the not-found page). A traditional request gets the recovery page as a complete document; an htmx request gets just the page's body content, since the document shell is already on the client.
|
|
85
91
|
|
|
86
92
|
**If the recovery itself raises** — a bug in your error component, say — Weft stops walking and emits a minimal hardcoded error rendering, logging the recovery failure and surfacing the *original* error. There is always a floor; error handling never recurses into itself.
|
|
87
93
|
|
|
88
|
-
|
|
94
|
+
Whatever renders, it wears the **failing component's DOM id**. A recovery fragment stands in the failed component's place, and an out-of-band swap addressed anywhere else would land on the wrong element — or on none. You don't opt into this and can't forget it; Weft stamps the id on every recovery fragment it produces.
|
|
95
|
+
|
|
96
|
+
### Where the work that can fail belongs
|
|
97
|
+
|
|
98
|
+
Two Weft surfaces can raise, and they are not equivalent. An action callable — `performs`, `transfers`, `dismisses` — is where your app *does* something: writes, external calls, state changes. A `build` method is where it *describes* something.
|
|
99
|
+
|
|
100
|
+
**Put the fallible work in the callable and keep `build` free of side effects.** The reason will be familiar from any MVC framework: by the time rendering begins, whatever the action committed is already committed. A failure inside the callable can still be reported as a failure of the whole operation; a failure during rendering can only be reported after the fact — which is why Rails rolls back a controller error but not a view error.
|
|
101
|
+
|
|
102
|
+
Weft leans into that split rather than papering over it. An action that raises produces an error *response*; a fragment that raises produces an error *fragment*, and everything around it stands. The more your `build` methods are pure descriptions of state, the more that second case is a display problem rather than a correctness one.
|
|
103
|
+
|
|
104
|
+
### When a companion fails
|
|
105
|
+
|
|
106
|
+
A component can bring [companions](dsl.md#includes--companions-in-the-same-response) along with a response — other fragments that went stale and ride back on the same request. **A companion is a courtesy, not a contract:** if one raises, the response still belongs to the component the request was about.
|
|
107
|
+
|
|
108
|
+
So the primary render, the status, and the `HX-*` headers are all untouched. The failing companion walks *its own* `recovers` chain, and the result is delivered as a companion in that companion's own DOM slot — the error appears exactly where that fragment would have been and nowhere else, while the rest of the response arrives as though nothing happened. This is what makes an action with side effects honest: a companion that breaks *after* your callable has written to the database can no longer turn a committed change into a reported failure.
|
|
89
109
|
|
|
90
|
-
|
|
110
|
+
Two wrinkles worth knowing:
|
|
91
111
|
|
|
92
|
-
|
|
112
|
+
- **Recovery edges that point at a page class are skipped here**, and the walk continues to the next match. A fragment riding inside a successful response has no business navigating away from it.
|
|
113
|
+
- **If the chain yields nothing, or the recovery render itself raises**, that companion is dropped and the failure is logged with the class and the `includes` declaration site that brought it along. The rest of the response is unaffected either way.
|
|
93
114
|
|
|
94
|
-
|
|
115
|
+
Companion failures on a [live stream](#error-handling-on-live-streams) behave the same, with one addition: they don't count against the stream's attempts budget. The budget measures the *stream's* health, and a companion's trouble says nothing about it.
|
|
116
|
+
|
|
117
|
+
## Error handling on live streams
|
|
118
|
+
|
|
119
|
+
A failing SSE push walks the same `recovers` chain as any other component-context failure, with three stream-shaped differences:
|
|
120
|
+
|
|
121
|
+
- **Component targets only.** A stream can't redirect, so an entry whose target is a page class is skipped and the walk continues to the next match. The gem-default `StandardError` edge sits at the bottom of every chain, so a failing push always finds an error component to render (unless you've deliberately reconfigured that default away).
|
|
122
|
+
- **Frames mirror normal pushes.** The recovery component's *content* is pushed under the failing component's event name and swaps into the persistent wrapper's interior, exactly like a healthy frame. The recovery component's own wrapper never ships — so on this path, put the error box and its styling on inner elements (the gem defaults do).
|
|
123
|
+
- **Failure is budgeted.** Consecutive failed pushes count against an attempts budget — the [`push_attempts`](configuration.md#push_attempts) setting (default 3), or per component with `pushes every: ..., attempts: ...`. A successful push resets the count. When the budget runs out, Weft pushes the final recovery frame, then a close event that the wrapper's `sse-close` attribute tells htmx to honor: the browser closes the EventSource and does not reconnect. The stream ends server-side too; the rest of the page is untouched.
|
|
124
|
+
|
|
125
|
+
The countdown is visible to your error components through the `:attempts_remaining` auto-injected param (below): it reaches 0 on the final frame — the moment to offer a resume affordance. The [`reopen_stream:` preset](dsl.md#presets) makes that a one-liner: `button "Resume live updates", reopen_stream: @params.retry_url` re-fetches the component whole, and the fresh render carries a fresh `sse-connect`, so the stream reopens with a full budget.
|
|
126
|
+
|
|
127
|
+
If the recovery render itself raises, that frame is skipped (and the failure still counts) — streams share the no-recursion floor with every other error path.
|
|
128
|
+
|
|
129
|
+
## Auto-injected recovery params
|
|
130
|
+
|
|
131
|
+
A recovery target usually wants context: what failed, where, with what status. The Router offers six values, delivered as **request overlays**: a component reads each by *declaring a param of that name*. The declaration is the opt-in — anything not declared is never read — and because overlays reach the whole recovery render, a component *nested inside* your branded error page can declare and read them too (a shared error-detail partial reading `:exception` itself, say). Recovery redirect URLs stay schema-gated and carry only the redirect-safe values the destination declares, so nothing rides a URL uninvited.
|
|
132
|
+
|
|
133
|
+
| Param | Value |
|
|
95
134
|
| --- | --- |
|
|
96
135
|
| `:exception` | The exception object itself. |
|
|
97
136
|
| `:request_path` | The path of the failing request. |
|
|
98
137
|
| `:status_code` | The resolved HTTP status (the exception's, or 500). |
|
|
99
|
-
| `:
|
|
100
|
-
| `:retry_url` | A GET URL that re-renders the failing component with its current
|
|
138
|
+
| `:component_tag` | The failing component's wrapper tag name. |
|
|
139
|
+
| `:retry_url` | A GET URL that re-renders the failing component with its current params. |
|
|
140
|
+
| `:attempts_remaining` | On a live stream: failed pushes left before the stream closes. Absent elsewhere. |
|
|
101
141
|
|
|
102
142
|
So a custom error component opts in by declaration:
|
|
103
143
|
|
|
@@ -105,26 +145,28 @@ So a custom error component opts in by declaration:
|
|
|
105
145
|
class MyApp::ErrorComponent < Weft::Component
|
|
106
146
|
abstract!
|
|
107
147
|
|
|
108
|
-
|
|
109
|
-
|
|
148
|
+
param :exception
|
|
149
|
+
param :retry_url
|
|
110
150
|
|
|
111
151
|
def build(attributes = {})
|
|
112
152
|
super
|
|
113
153
|
add_class "weft-error"
|
|
114
154
|
div { text_node "Something went wrong." }
|
|
115
|
-
div @
|
|
116
|
-
button "Retry", retry: @
|
|
155
|
+
div @params.exception.message if Weft.configuration.verbose_error_pages
|
|
156
|
+
button "Retry", retry: @params.retry_url if @params.retry_url
|
|
117
157
|
end
|
|
118
158
|
end
|
|
119
159
|
```
|
|
120
160
|
|
|
121
161
|
Notes on the individual values:
|
|
122
162
|
|
|
123
|
-
- **These
|
|
124
|
-
-
|
|
125
|
-
- **`:
|
|
163
|
+
- **These six names are reserved** on any class used as a recovery target. Declaring a param with one of these names *means* "inject the recovery value here" — so don't reuse them for your own data on error components, or on any component/page reachable through a `recovers` edge.
|
|
164
|
+
- **DOM identity is not among them**, because it isn't optional. Weft stamps the failing component's id onto every recovery fragment, so simultaneous failures each swap into their own slot rather than colliding, and a recovery target that has never heard of any of this still lands correctly.
|
|
165
|
+
- **`:component_tag`** keeps swaps *valid*: return it from your error component's `tag_name` (the gem's defaults do) and a failure inside a `<tr>` or `<li>` component produces a fragment its surroundings can legally contain, instead of a `<div>` forced somewhere divs can't go. Weft reads the tag without re-running the failed construction; when a component computes its tag from instance state, the value falls back to absent and the target renders with its own default tag.
|
|
166
|
+
- **`:retry_url`** feeds the [`retry:` preset](dsl.md#presets): one button attribute, and the user can re-request the failed component in place. For a failed *action*, the URL renders the underlying component's view — a fresh look, not a replay of the failed action. On a stream's final frame it feeds [`reopen_stream:`](dsl.md#presets) the same way.
|
|
167
|
+
- **`:attempts_remaining`** is also the push-path context signal: non-nil only when rendering a recovery frame for a live stream. Its presence lets one error component serve both paths — the gem default renders its request shape when it's nil and its stream shape otherwise.
|
|
126
168
|
- When a recovery resolves to a **redirect** (page target from component context), only `:request_path` and `:status_code` travel — the others have no meaning in a URL.
|
|
127
|
-
- Keep the `weft-error` CSS class on custom error components: it's the DOM marker the `retry:`
|
|
169
|
+
- Keep the `weft-error` CSS class on custom error components: it's the DOM marker the `retry:` preset targets, and a useful styling hook besides.
|
|
128
170
|
|
|
129
171
|
## Presentation settings
|
|
130
172
|
|
|
@@ -132,5 +174,3 @@ Two configuration settings shape how the built-in fallbacks present; both are co
|
|
|
132
174
|
|
|
133
175
|
- [`verbose_error_pages`](configuration.md#verbose_error_pages) — whether the gem defaults show exception class/message and the failing path (turn off in production).
|
|
134
176
|
- [`htmx_errors`](configuration.md#htmx_errors) — whether htmx-request errors falling through to the gem defaults render in place (`:fragment`) or navigate to the error page (`:redirect`). Your own `recovers` edges are never affected, and 404s always render in place.
|
|
135
|
-
|
|
136
|
-
> **v0.1 limitation:** custom `recovers from: Weft::NotFound` declarations are not yet reliably honored — the gem-default not-found rendering can take over the response. To customize not-found presentation in v0.1, assign the [`not_found_page` / `not_found_component` knobs](configuration.md#the-four-fallback-targets), which are fully supported. First-class custom `NotFound` recoveries land in v0.2.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A search box that filters a result list as the user types. Each (debounced) keystroke asks the server for matches, and the results region is refilled with the answer — search-as-you-type with all the logic living server-side.
|
|
4
4
|
|
|
5
|
-
This is Weft's take on [htmx's active-search example](https://htmx.org/examples/active-search/), searching the same kind of small contact directory by name or email. One preset detail differs and is worth stating precisely: Weft's `live_search:`
|
|
5
|
+
This is Weft's take on [htmx's active-search example](https://htmx.org/examples/active-search/), searching the same kind of small contact directory by name or email. One preset detail differs and is worth stating precisely: Weft's `live_search:` preset fires on `input changed delay:300ms` — the `input` event, debounced by 300 milliseconds — where htmx's published example uses `keyup changed delay:500ms`. If you want htmx's exact timing (or any other), the `trigger:` kwarg overrides the preset, as shown below.
|
|
6
6
|
|
|
7
7
|
## The components
|
|
8
8
|
|
|
@@ -21,13 +21,13 @@ PEOPLE = [
|
|
|
21
21
|
class ContactResults < Weft::Component
|
|
22
22
|
builder_method :contact_results
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
param :q, default: ""
|
|
25
25
|
|
|
26
26
|
def build(attributes = {})
|
|
27
27
|
super
|
|
28
|
-
matches = search(
|
|
28
|
+
matches = search(params.q)
|
|
29
29
|
if matches.empty?
|
|
30
|
-
para "No one matches “#{
|
|
30
|
+
para "No one matches “#{params.q}”."
|
|
31
31
|
else
|
|
32
32
|
table do
|
|
33
33
|
thead { tr { th "Name"; th "Email" } }
|
|
@@ -55,7 +55,7 @@ class ContactSearch < Weft::Component
|
|
|
55
55
|
input type: "search", name: "q", placeholder: "Begin typing to search...",
|
|
56
56
|
live_search: ContactResults, target: "#search-results"
|
|
57
57
|
div id: "search-results" do
|
|
58
|
-
contact_results
|
|
58
|
+
contact_results
|
|
59
59
|
end
|
|
60
60
|
end
|
|
61
61
|
end
|
|
@@ -65,7 +65,7 @@ end
|
|
|
65
65
|
|
|
66
66
|
## How it works
|
|
67
67
|
|
|
68
|
-
**The input's `name` is the search parameter.** [`live_search:`](../dsl.md#
|
|
68
|
+
**The input's `name` is the search parameter.** [`live_search:`](../dsl.md#presets) presets trigger `:input` and swap `:fill`; the call site supplies the target. Notice that the generated URL below carries no query string — htmx includes the triggering input's own `name`/value with the request, which is how the typed text travels. The two halves must agree: the input says `name: "q"`, and `ContactResults` declares `param :q` to receive it. An empty default keeps the blank-box case (matching everyone) working.
|
|
69
69
|
|
|
70
70
|
**Debounced by the preset, adjustable at the call site.** The `:input` semantic trigger expands to `input changed delay:300ms`: fire on input events, only when the value actually changed, at most once per 300ms lull. To reproduce htmx's example exactly, override it in place — the preset's request, swap, and target are all kept:
|
|
71
71
|
|
|
@@ -75,7 +75,7 @@ input type: "search", name: "q",
|
|
|
75
75
|
trigger: "keyup changed delay:500ms"
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
**Results refill a stable container.** The `:fill` swap replaces the *contents* of `#search-results`, so the container div persists across searches while a fresh `ContactResults` lands inside it each time. Pre-rendering `contact_results
|
|
78
|
+
**Results refill a stable container.** The `:fill` swap replaces the *contents* of `#search-results`, so the container div persists across searches while a fresh `ContactResults` lands inside it each time. Pre-rendering a bare `contact_results` in the container means the page starts with the full directory rather than an empty pane — its `q` param defaults to the empty string, which matches everyone — and the initial state and every subsequent state are the same component.
|
|
79
79
|
|
|
80
80
|
**The whole result set re-renders per search.** No row diffing, no client-side state: each request returns the complete table (or the "no matches" paragraph) for that query. At search-box scale this is the simple, correct trade.
|
|
81
81
|
|
|
@@ -94,7 +94,7 @@ The initial render — the wired input, and the container holding the unfiltered
|
|
|
94
94
|
</div>
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
-
(That `id="contact-results-"` is the component's DOM id — dasherized class name plus first
|
|
97
|
+
(That `id="contact-results-"` is the component's DOM id — dasherized class name plus first param value, which here is the empty string.)
|
|
98
98
|
|
|
99
99
|
Typing "grimes" settles into `GET /_components/contact_results?q=grimes`:
|
|
100
100
|
|
|
@@ -121,5 +121,5 @@ And a query with no matches (`GET /_components/contact_results?q=zz`) returns th
|
|
|
121
121
|
## Related
|
|
122
122
|
|
|
123
123
|
- [Tabs](tabs.md) — the same fill-a-stable-container shape, driven by clicks instead of keystrokes.
|
|
124
|
-
- The [
|
|
125
|
-
- The [tutorial](../tutorial.md) covers the pairing of form field names with declared
|
|
124
|
+
- The [presets table](../dsl.md#presets) and [trigger values](../dsl.md#trigger-values) in the DSL reference.
|
|
125
|
+
- The [tutorial](../tutorial.md) covers the pairing of form field names with declared params in depth.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A destructive button guarded by the browser's native `confirm()` dialog — the user gets a chance to back out before the request is ever made, with no dialog component to build and no CSS to write.
|
|
4
4
|
|
|
5
|
-
This is Weft's take on [htmx's dialogs example](https://htmx.org/examples/dialogs/). The
|
|
5
|
+
This is Weft's take on [htmx's dialogs example](https://htmx.org/examples/dialogs/). The confirm half is one kwarg — [`confirm:`](../dsl.md#confirm) rides the same button the action already wired. The prompt half is deliberately *not* a kwarg (below), and shows the escape hatch that remains for htmx vocabulary Weft doesn't cover.
|
|
6
6
|
|
|
7
7
|
## The components
|
|
8
8
|
|
|
@@ -22,7 +22,7 @@ class AccountPanel < Weft::Component
|
|
|
22
22
|
para "Your account is #{ACCOUNT[:status]}."
|
|
23
23
|
if ACCOUNT[:status] == "active"
|
|
24
24
|
button "Deactivate my account", action: :deactivate,
|
|
25
|
-
|
|
25
|
+
confirm: "Deactivate your account? You can sign back in to reactivate."
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
end
|
|
@@ -32,25 +32,25 @@ end
|
|
|
32
32
|
|
|
33
33
|
## How it works
|
|
34
34
|
|
|
35
|
-
**
|
|
35
|
+
**The guard is one modifier kwarg.** [`confirm:`](../dsl.md#confirm) works alongside any interaction kwarg — this action, a [`navigate:`](../dsl.md#navigate), a preset — or standalone on a container, where htmx inheritance applies it to every request fired from inside. Weft consumes the kwarg and emits `hx-confirm` beside the wiring [`action:`](../dsl.md#action) expanded.
|
|
36
36
|
|
|
37
|
-
**The guard lives in the browser, not on the server.**
|
|
37
|
+
**The guard lives in the browser, not on the server.** The confirm gates the *request*: htmx shows the native dialog and only issues the POST if the user accepts. The endpoint itself is unchanged — a request made outside htmx skips the question entirely. Treat it as protection against misclicks, never as access control; anything truly destructive still needs authorization server-side.
|
|
38
38
|
|
|
39
39
|
**The action is ordinary Weft.** `performs :deactivate` runs the write and re-renders the component, which now shows the deactivated state — the standard action contract, unaware that a dialog ever happened.
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
**There is no `prompt:` kwarg — on purpose.** htmx's companion attribute asks for a line of text and sends the answer as an `HX-Prompt` *request header* — but a Weft action callable receives only the component's resolved params, which come from request parameters, so the prompted value would never reach your code. When an action needs user input, give the component a real input: a form field paired with a declared param, as in [Click to Edit](click-to-edit.md). And if you want the browser's prompt anyway, the escape hatch is always open: kwargs Weft doesn't recognize pass through untouched, so `"hx-prompt" => "..."` renders verbatim — the general pattern whenever htmx has a feature Weft has no vocabulary for.
|
|
42
42
|
|
|
43
43
|
## On the wire
|
|
44
44
|
|
|
45
|
-
The initial render — the confirm
|
|
45
|
+
The initial render — the confirm lands beside the expanded action wiring:
|
|
46
46
|
|
|
47
47
|
```html
|
|
48
48
|
<div id="account-panel">
|
|
49
49
|
<p>Your account is active.</p>
|
|
50
|
-
<button hx-
|
|
51
|
-
hx-post="/_components/account_panel/deactivate"
|
|
50
|
+
<button hx-post="/_components/account_panel/deactivate"
|
|
52
51
|
hx-target="#account-panel" hx-swap="outerHTML"
|
|
53
|
-
hx-vals="{}"
|
|
52
|
+
hx-vals="{}"
|
|
53
|
+
hx-confirm="Deactivate your account? You can sign back in to reactivate.">Deactivate my account</button>
|
|
54
54
|
</div>
|
|
55
55
|
```
|
|
56
56
|
|
|
@@ -67,5 +67,5 @@ The same POST sent from outside the browser — no htmx, no dialog — is accept
|
|
|
67
67
|
## Related
|
|
68
68
|
|
|
69
69
|
- [Modal Dialog](modal-dialog.md) — when you want a dialog you own instead of the browser's.
|
|
70
|
-
- [Click to Edit](click-to-edit.md) — form fields paired with
|
|
70
|
+
- [Click to Edit](click-to-edit.md) — form fields paired with params: the Weft answer to "prompt the user for a value."
|
|
71
71
|
- [`action:`](../dsl.md#action) and [`performs`](../dsl.md#performs--user-initiated-actions) in the DSL reference.
|
|
@@ -17,11 +17,11 @@ TEAM_MEMBERS = {
|
|
|
17
17
|
class MemberRoster < Weft::Component
|
|
18
18
|
builder_method :member_roster
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
param :active_ids, default: []
|
|
21
|
+
param :status
|
|
22
22
|
|
|
23
|
-
performs :update, target: "#member-roster" do |
|
|
24
|
-
checked =
|
|
23
|
+
performs :update, target: "#member-roster" do |params|
|
|
24
|
+
checked = params.active_ids
|
|
25
25
|
activated = deactivated = 0
|
|
26
26
|
TEAM_MEMBERS.each do |id, member|
|
|
27
27
|
active = checked.include?(id)
|
|
@@ -56,7 +56,7 @@ class MemberRoster < Weft::Component
|
|
|
56
56
|
end
|
|
57
57
|
input type: "submit", value: "Bulk Update"
|
|
58
58
|
end
|
|
59
|
-
para
|
|
59
|
+
para params.status if params.status
|
|
60
60
|
end
|
|
61
61
|
end
|
|
62
62
|
```
|
|
@@ -65,15 +65,15 @@ end
|
|
|
65
65
|
|
|
66
66
|
## How it works
|
|
67
67
|
|
|
68
|
-
**Bracket naming turns the checkboxes into one array.** Every checkbox shares the name `active_ids[]`, and Rack's parameter parsing folds those into a single array under the bracket-less key — so the component's declared `active_ids`
|
|
68
|
+
**Bracket naming turns the checkboxes into one array.** Every checkbox shares the name `active_ids[]`, and Rack's parameter parsing folds those into a single array under the bracket-less key — so the component's declared `active_ids` param receives `["1", "2", "4"]`, and the callable reads it as `params.active_ids`. The values arrive as strings, which is why the data stub's keys are strings too. Checkboxes the user leaves unchecked simply aren't in the submission; that's the whole trick of the pattern.
|
|
69
69
|
|
|
70
|
-
**The `default: []` is load-bearing.** When *no* boxes are checked, the browser sends no `active_ids` parameter at all, and the
|
|
70
|
+
**The `default: []` is load-bearing.** When *no* boxes are checked, the browser sends no `active_ids` parameter at all, and the param falls back to its default. An empty array makes that case mean "deactivate everyone" — and guarantees the callable always has a real array to call `include?` on, rather than `nil`.
|
|
71
71
|
|
|
72
|
-
**The callable diffs, then reports through its return value.** It compares each member's stored state against the submitted array, counts the flips, and writes the new state. Returning a hash merges it into the
|
|
72
|
+
**The callable diffs, then reports through its return value.** It compares each member's stored state against the submitted array, counts the flips, and writes the new state. Returning a hash merges it into the params for the re-render (see [the callable contract](../dsl.md#the-callable-contract)), so `{ status: "Activated 1 and deactivated 1 members." }` is how the count reaches the status line — `params.status` is `nil` on a fresh render and the paragraph only appears after an update.
|
|
73
73
|
|
|
74
|
-
**An array can't anchor a DOM id.** Weft derives a component's DOM id from its first declared
|
|
74
|
+
**An array can't anchor a DOM id.** Weft derives a component's DOM id from its first declared param — perfect when that's a record id, unusable when it's an array (`id="member-roster-[]"` is not a selector htmx can target). So this component pins its own identity: `set_attribute :id, "member-roster"` fixes the wrapper's id inside `build`, and `performs :update, target: "#member-roster"` points the action's swap at that same anchor. Both live server-side, so every re-rendered fragment carries the same stable wiring.
|
|
75
75
|
|
|
76
|
-
**The checkboxes tell the truth after the write.** `build` renders each checkbox from the data store, not from the submitted
|
|
76
|
+
**The checkboxes tell the truth after the write.** `build` renders each checkbox from the data store, not from the submitted params — the response reflects what was actually saved. And since `form(action: :update)` also emits plain `action`/`method` attributes, the whole thing degrades to a normal POST without JavaScript.
|
|
77
77
|
|
|
78
78
|
## On the wire
|
|
79
79
|
|
|
@@ -117,6 +117,6 @@ Submitting with every box unchecked sends an empty body; the default kicks in an
|
|
|
117
117
|
|
|
118
118
|
## Related
|
|
119
119
|
|
|
120
|
-
- [Click to Edit](click-to-edit.md) — the basics of pairing form fields with declared
|
|
120
|
+
- [Click to Edit](click-to-edit.md) — the basics of pairing form fields with declared params.
|
|
121
121
|
- [Delete Row](delete-row.md) and [Edit Row](edit-row.md) — acting on table rows one at a time instead of all at once.
|
|
122
122
|
- [`performs`](../dsl.md#performs--user-initiated-actions) and [the callable contract](../dsl.md#the-callable-contract) in the DSL reference.
|
|
@@ -14,16 +14,16 @@ CONTACTS = {
|
|
|
14
14
|
class ContactCard < Weft::Component
|
|
15
15
|
builder_method :contact_card
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
param :contact_id
|
|
18
18
|
|
|
19
19
|
def build(attributes = {})
|
|
20
20
|
super
|
|
21
|
-
contact = CONTACTS.fetch(
|
|
21
|
+
contact = CONTACTS.fetch(params.contact_id)
|
|
22
22
|
div { strong "First Name: "; text_node contact[:first_name] }
|
|
23
23
|
div { strong "Last Name: "; text_node contact[:last_name] }
|
|
24
24
|
div { strong "Email: "; text_node contact[:email] }
|
|
25
25
|
button "Click To Edit",
|
|
26
|
-
loads: ContactEditor, with: { contact_id:
|
|
26
|
+
loads: ContactEditor, with: { contact_id: params.contact_id },
|
|
27
27
|
swap: :replace, target: self
|
|
28
28
|
end
|
|
29
29
|
end
|
|
@@ -31,23 +31,23 @@ end
|
|
|
31
31
|
class ContactEditor < Weft::Component
|
|
32
32
|
builder_method :contact_editor
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
param :contact_id
|
|
35
|
+
param :first_name
|
|
36
|
+
param :last_name
|
|
37
|
+
param :email
|
|
38
38
|
|
|
39
|
-
transfers :save, to: ContactCard do |
|
|
40
|
-
CONTACTS.fetch(
|
|
41
|
-
first_name:
|
|
39
|
+
transfers :save, to: ContactCard do |params|
|
|
40
|
+
CONTACTS.fetch(params.contact_id).merge!(
|
|
41
|
+
first_name: params.first_name, last_name: params.last_name, email: params.email
|
|
42
42
|
)
|
|
43
43
|
nil
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def build(attributes = {})
|
|
47
47
|
super
|
|
48
|
-
contact = CONTACTS.fetch(
|
|
48
|
+
contact = CONTACTS.fetch(params.contact_id)
|
|
49
49
|
form(action: :save) do
|
|
50
|
-
input(type: "hidden", name: "contact_id", value:
|
|
50
|
+
input(type: "hidden", name: "contact_id", value: params.contact_id)
|
|
51
51
|
div do
|
|
52
52
|
label("First Name ", for: "first_name")
|
|
53
53
|
input(type: "text", name: "first_name", id: "first_name", value: contact[:first_name])
|
|
@@ -62,7 +62,7 @@ class ContactEditor < Weft::Component
|
|
|
62
62
|
end
|
|
63
63
|
input(type: "submit", value: "Submit")
|
|
64
64
|
button "Cancel", type: "button",
|
|
65
|
-
loads: ContactCard, with: { contact_id:
|
|
65
|
+
loads: ContactCard, with: { contact_id: params.contact_id },
|
|
66
66
|
swap: :replace, target: self
|
|
67
67
|
end
|
|
68
68
|
end
|
|
@@ -79,7 +79,7 @@ end
|
|
|
79
79
|
|
|
80
80
|
**The two components reference each other — without a cycle.** `transfers :save, to: ContactCard` runs in the class body, so `ContactCard` must already be defined; but `loads: ContactEditor` isn't evaluated until render. Defining the display component first therefore breaks the loop with no forward-declaration tricks. This ordering trick generalizes to any two-state component pair.
|
|
81
81
|
|
|
82
|
-
**Form fields pair with declared
|
|
82
|
+
**Form fields pair with declared params.** The editor declares `first_name`, `last_name`, and `email` so its fields reach the save callable as `params.first_name` and friends — and `contact_id` rides along as a hidden input, because it's part of the component's identity rather than something the user edits. (This pairing is covered in depth in [the tutorial](../tutorial.md#7-taking-rsvps).)
|
|
83
83
|
|
|
84
84
|
**It still works without JavaScript.** `form(action: :save)` emits plain `action`/`method` attributes alongside the htmx wiring, so the save degrades to a normal POST. Note `type: "button"` on Cancel — inside a form, a bare `<button>` is a submit button.
|
|
85
85
|
|
|
@@ -14,16 +14,16 @@ class AgentRows < Weft::Component
|
|
|
14
14
|
|
|
15
15
|
PER_PAGE = 6
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
param :page, default: 1, type: :integer
|
|
18
18
|
|
|
19
19
|
def build(attributes = {})
|
|
20
20
|
super
|
|
21
|
-
batch = AGENTS[(
|
|
21
|
+
batch = AGENTS[(params.page - 1) * PER_PAGE, PER_PAGE]
|
|
22
22
|
batch.each do |agent|
|
|
23
23
|
div { strong agent[:name]; text_node " — #{agent[:email]} (##{agent[:number]})" }
|
|
24
24
|
end
|
|
25
|
-
if
|
|
26
|
-
button "Load More Agents...", load_more: AgentRows, with: { page:
|
|
25
|
+
if params.page * PER_PAGE < AGENTS.size
|
|
26
|
+
button "Load More Agents...", load_more: AgentRows, with: { page: params.page + 1 }
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
end
|
|
@@ -31,15 +31,15 @@ end
|
|
|
31
31
|
|
|
32
32
|
(The `AGENTS` array stands in for your data layer — swap in ActiveRecord or whatever your app uses.)
|
|
33
33
|
|
|
34
|
-
Render `agent_rows
|
|
34
|
+
Render a bare `agent_rows` wherever the list lives — its `page` param defaults to 1 — and the pattern takes care of itself from there.
|
|
35
35
|
|
|
36
36
|
## How it works
|
|
37
37
|
|
|
38
|
-
**One
|
|
38
|
+
**One preset, no ceremony.** [`load_more:`](../dsl.md#presets) is a preset over the [`loads:`](../dsl.md#loads) machinery: trigger `:click`, swap `:replace`, target `:self`. In plain terms — when this button is clicked, fetch the named component and put it where the button was. The call site supplies only what varies: which component to load (`AgentRows`, the component's own class) and its wire params (`with: { page: params.page + 1 }`).
|
|
39
39
|
|
|
40
40
|
**The component is a chunk, not the whole list.** Each `AgentRows` instance renders one page of agents and, when more remain, the button that fetches the next chunk *in its own place*. Clicking never touches the agents already on screen; the button alone is replaced, and the new chunk arrives with its own button. The recursion bottoms out naturally — the `if` guard means the final chunk simply renders no button.
|
|
41
41
|
|
|
42
|
-
**
|
|
42
|
+
**Params make the chunk addressable.** Declaring `param :page, default: 1, type: :integer` gives the component a route ([Routing](../routing.md)) and coerces the wire value: `page=2` arrives as the string `"2"` and reaches `params.page` as the Integer `2`.
|
|
43
43
|
|
|
44
44
|
**A component can load itself.** `load_more: AgentRows` inside `AgentRows`'s own `build` is unremarkable — the class reference is evaluated at render time, so self-reference needs no tricks.
|
|
45
45
|
|
|
@@ -73,5 +73,5 @@ The last page (`GET /_components/agent_rows?page=4`) renders its agents and no b
|
|
|
73
73
|
|
|
74
74
|
- [Infinite Scroll](infinite-scroll.md) — the same next-page mechanic, triggered by scrolling instead of a click, in a real table.
|
|
75
75
|
- [Lazy Loading](lazy-loading.md) — deferring one expensive section rather than paginating many.
|
|
76
|
-
- The [
|
|
76
|
+
- The [presets table](../dsl.md#presets) in the DSL reference, including how to register your own preset.
|
|
77
77
|
- For pagination that *replaces* the current page instead of accumulating, [`navigate:`](../dsl.md#navigate) is the better verb.
|
data/docs/examples/delete-row.md
CHANGED
|
@@ -16,10 +16,11 @@ CONTACT_BOOK = {
|
|
|
16
16
|
class ContactRow < Weft::Component
|
|
17
17
|
builder_method :contact_row
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
param :contact_id
|
|
20
|
+
receives :contact_id
|
|
20
21
|
|
|
21
|
-
dismisses :destroy do |
|
|
22
|
-
CONTACT_BOOK.delete(
|
|
22
|
+
dismisses :destroy do |params|
|
|
23
|
+
CONTACT_BOOK.delete(params.contact_id)
|
|
23
24
|
nil
|
|
24
25
|
end
|
|
25
26
|
|
|
@@ -29,14 +30,12 @@ class ContactRow < Weft::Component
|
|
|
29
30
|
|
|
30
31
|
def build(attributes = {})
|
|
31
32
|
super
|
|
32
|
-
contact = CONTACT_BOOK[
|
|
33
|
-
return unless contact
|
|
34
|
-
|
|
33
|
+
contact = CONTACT_BOOK[params.contact_id]
|
|
35
34
|
td contact[:name]
|
|
36
35
|
td contact[:email]
|
|
37
36
|
td contact[:status]
|
|
38
37
|
td do
|
|
39
|
-
button "Delete", action: :destroy,
|
|
38
|
+
button "Delete", action: :destroy, confirm: "Are you sure?"
|
|
40
39
|
end
|
|
41
40
|
end
|
|
42
41
|
end
|
|
@@ -60,13 +59,15 @@ end
|
|
|
60
59
|
|
|
61
60
|
## How it works
|
|
62
61
|
|
|
63
|
-
**The row is the component.** Overriding `tag_name` makes the wrapper a `<tr>`, so each contact renders as a real table row with its own DOM id. The identifying
|
|
62
|
+
**The row is the component.** Overriding `tag_name` makes the wrapper a `<tr>`, so each contact renders as a real table row with its own DOM id. The identifying param is declared first because that's where the id comes from — `contact_id` of `"1"` yields `id="contact-row-1"`, which is exactly what the delete needs to target.
|
|
63
|
+
|
|
64
|
+
**Each row is handed its id, and declares it as a param too.** The table gives every row a *different* `contact_id` (`contact_row(contact_id: id)`), which is a [`receives`](../dsl.md#receives--caller-hand-offs) hand-off — sibling rows each need a distinct value, and the shared params bag that flows down the render tree can't supply per-row differences. Declaring [`param :contact_id`](../dsl.md#how-the-doors-combine) alongside it means the same id also serializes into the row's own route and the Delete button's payload, so `GET /_components/contact_row?contact_id=1` reconstructs the row on its own and the delete targets the right record.
|
|
64
65
|
|
|
65
|
-
**`dismisses` is the delete-shaped verb.** It's sugar for a `performs` with `method: :delete, swap: :delete` (see [`dismisses`](../dsl.md#dismisses--remove-from-the-dom)): the button wired with `action: :destroy` issues a `DELETE` to the action's route, the callable removes the record, and on success htmx deletes the target element — the row — from the DOM. The row's identity travels automatically: an action button carries the component's
|
|
66
|
+
**`dismisses` is the delete-shaped verb.** It's sugar for a `performs` with `method: :delete, swap: :delete` (see [`dismisses`](../dsl.md#dismisses--remove-from-the-dom)): the button wired with `action: :destroy` issues a `DELETE` to the action's route, the callable removes the record, and on success htmx deletes the target element — the row — from the DOM. The row's identity travels automatically: an action button carries the component's params as `hx-vals`. Note there's no non-JavaScript fallback here — plain HTML has no DELETE — which is the nature of the pattern rather than a Weft limitation.
|
|
66
67
|
|
|
67
|
-
**The confirmation is one
|
|
68
|
+
**The confirmation is one kwarg.** [`confirm:`](../dsl.md#confirm) rides along on the wired button: htmx shows the browser's native confirm dialog before sending anything, and no request fires on Cancel. For the fuller confirm-and-prompt story, see [Browser Dialogs](browser-dialogs.md).
|
|
68
69
|
|
|
69
|
-
**
|
|
70
|
+
**A successful dismissal responds with nothing.** htmx removes the row on its own, so the response carries no body — Weft doesn't render the component it just helped delete, which is why `build` can assume its record exists without guarding. (Out-of-band [`includes`](../dsl.md#includes--companions-in-the-same-response) companions still ride the response when declared — a delete can update the count beside the table it shrinks.) The trailing `nil` in the callable keeps `Hash#delete`'s return value — the deleted record — from merging into the params those companions read. If the callable *raises*, Weft overrides the destructive swap (via `HX-Reswap`) so the error rendering appears where the row was, instead of the row silently vanishing — and the error fragment arrives as a `<tr>` itself, because recovery fragments [adopt the failing component's tag](../error-handling.md#auto-injected-recovery-params), landing as valid table content.
|
|
70
71
|
|
|
71
72
|
## On the wire
|
|
72
73
|
|
|
@@ -78,20 +79,17 @@ Each row arrives fully wired — `GET /_components/contact_row?contact_id=1` ret
|
|
|
78
79
|
<td>angie@macdowell.org</td>
|
|
79
80
|
<td>Active</td>
|
|
80
81
|
<td>
|
|
81
|
-
<button hx-
|
|
82
|
+
<button hx-delete="/_components/contact_row/destroy"
|
|
82
83
|
hx-target="#contact-row-1" hx-swap="delete"
|
|
83
|
-
hx-vals="{"contact_id":"1"}"
|
|
84
|
+
hx-vals="{"contact_id":"1"}"
|
|
85
|
+
hx-confirm="Are you sure?">Delete</button>
|
|
84
86
|
</td>
|
|
85
87
|
</tr>
|
|
86
88
|
```
|
|
87
89
|
|
|
88
|
-
Confirming the dialog sends `DELETE /_components/contact_row/destroy?contact_id=1` (htmx 2 puts DELETE parameters in the query string). The server deletes the record and responds `200` with
|
|
89
|
-
|
|
90
|
-
```html
|
|
91
|
-
<tr id="contact-row-1"></tr>
|
|
92
|
-
```
|
|
90
|
+
Confirming the dialog sends `DELETE /_components/contact_row/destroy?contact_id=1` (htmx 2 puts DELETE parameters in the query string). The server deletes the record and responds `200` with an empty body — there is nothing left worth rendering — and htmx removes the row.
|
|
93
91
|
|
|
94
|
-
Fetching the table again shows two rows
|
|
92
|
+
Fetching the table again shows two rows. Fetching the deleted row's own URL now fails like any other missing-record lookup and lands in the error chain — if stale fetches are a real path in your app, brand them with [`recovers from:`](../error-handling.md).
|
|
95
93
|
|
|
96
94
|
## Related
|
|
97
95
|
|