weft 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 +37 -0
- data/LICENSE.txt +21 -0
- data/README.md +150 -0
- data/docs/app-patterns.md +268 -0
- data/docs/arbre.md +298 -0
- data/docs/configuration.md +219 -0
- data/docs/dsl.md +329 -0
- data/docs/error-handling.md +136 -0
- data/docs/examples/README.md +70 -0
- data/docs/examples/active-search.md +125 -0
- data/docs/examples/browser-dialogs.md +71 -0
- data/docs/examples/bulk-update.md +122 -0
- data/docs/examples/click-to-edit.md +113 -0
- data/docs/examples/click-to-load.md +77 -0
- data/docs/examples/delete-row.md +101 -0
- data/docs/examples/edit-row.md +146 -0
- data/docs/examples/file-upload.md +96 -0
- data/docs/examples/infinite-scroll.md +98 -0
- data/docs/examples/inline-expansion.md +98 -0
- data/docs/examples/inline-validation.md +101 -0
- data/docs/examples/keyboard-shortcuts.md +71 -0
- data/docs/examples/lazy-loading.md +96 -0
- data/docs/examples/live-ticker.md +91 -0
- data/docs/examples/modal-dialog.md +88 -0
- data/docs/examples/progress-bar.md +138 -0
- data/docs/examples/reset-user-input.md +101 -0
- data/docs/examples/tabs.md +102 -0
- data/docs/examples/tooltip.md +88 -0
- data/docs/examples/updating-other-content.md +169 -0
- data/docs/examples/value-select.md +109 -0
- data/docs/routing.md +131 -0
- data/docs/tutorial.md +372 -0
- data/lib/weft/action.rb +83 -0
- data/lib/weft/attributes.rb +65 -0
- data/lib/weft/component.rb +176 -0
- data/lib/weft/configuration.rb +177 -0
- data/lib/weft/context/interception.rb +22 -0
- data/lib/weft/context.rb +184 -0
- data/lib/weft/defaults/error_component.rb +63 -0
- data/lib/weft/defaults/error_page.rb +27 -0
- data/lib/weft/defaults/not_found_component.rb +44 -0
- data/lib/weft/defaults/not_found_page.rb +25 -0
- data/lib/weft/defaults.rb +9 -0
- data/lib/weft/dsl/actions.rb +72 -0
- data/lib/weft/dsl/attributes.rb +43 -0
- data/lib/weft/dsl/containers.rb +77 -0
- data/lib/weft/dsl/inclusions.rb +49 -0
- data/lib/weft/dsl/recoveries.rb +89 -0
- data/lib/weft/dsl/triggers.rb +40 -0
- data/lib/weft/dsl/updates.rb +86 -0
- data/lib/weft/error.rb +64 -0
- data/lib/weft/page.rb +371 -0
- data/lib/weft/redirect.rb +45 -0
- data/lib/weft/registry/eligibility.rb +58 -0
- data/lib/weft/registry.rb +202 -0
- data/lib/weft/resolver.rb +33 -0
- data/lib/weft/router/actions.rb +77 -0
- data/lib/weft/router/errors.rb +246 -0
- data/lib/weft/router/oob_includes.rb +34 -0
- data/lib/weft/router/streaming.rb +63 -0
- data/lib/weft/router.rb +191 -0
- data/lib/weft/shorthands.rb +57 -0
- data/lib/weft/version.rb +5 -0
- data/lib/weft.rb +124 -0
- metadata +169 -0
data/docs/dsl.md
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# The Weft DSL
|
|
2
|
+
|
|
3
|
+
A Weft component describes its interactive behavior in two layers. **Class-body declarations** — the verbs — state what the component does: it refreshes on a timer, it performs an action, it recovers from an error. **Element kwargs**, used inside `build`, wire individual elements to those behaviors: this button performs the `:cancel` action, this div loads a tooltip on hover. Both layers compile down to auto-generated routes and htmx attributes; you write neither by hand. (The HTML itself — the `build` method and everything inside it — is [Arbre](arbre.md), documented separately.)
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
class DeliveryStatus < Weft::Component
|
|
7
|
+
attribute :delivery_id # wire state
|
|
8
|
+
|
|
9
|
+
refreshes every: 5.seconds # verb: live updates
|
|
10
|
+
|
|
11
|
+
performs :cancel do |attrs| # verb: user-initiated action
|
|
12
|
+
CancelDelivery.call(Delivery.find(attrs.delivery_id))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def build(attributes = {})
|
|
16
|
+
super
|
|
17
|
+
delivery = Delivery.find(attrs.delivery_id)
|
|
18
|
+
span "Arriving #{delivery.eta.humanize}"
|
|
19
|
+
button "Cancel", action: :cancel # element kwarg: wires to the verb
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**In this document:**
|
|
25
|
+
|
|
26
|
+
- [Attributes](#attributes)
|
|
27
|
+
- [Verbs](#verbs)
|
|
28
|
+
- [`refreshes` — the client re-fetches](#refreshes--the-client-re-fetches)
|
|
29
|
+
- [`pushes` — the server sends updates](#pushes--the-server-sends-updates)
|
|
30
|
+
- [`performs` — user-initiated actions](#performs--user-initiated-actions) and [the callable contract](#the-callable-contract)
|
|
31
|
+
- [`transfers` — actions that render something else](#transfers--actions-that-render-something-else)
|
|
32
|
+
- [`dismisses` — remove from the DOM](#dismisses--remove-from-the-dom)
|
|
33
|
+
- [`triggers` — announce to the rest of the page](#triggers--announce-to-the-rest-of-the-page)
|
|
34
|
+
- [`includes` — companions in the same response](#includes--companions-in-the-same-response)
|
|
35
|
+
- [`recovers` — declare error behavior](#recovers--declare-error-behavior)
|
|
36
|
+
- [Other class-body declarations](#other-class-body-declarations)
|
|
37
|
+
- [Element kwargs](#element-kwargs): [`action:`](#action), [`navigate:`](#navigate), [`loads:`](#loads), [`trigger:`](#trigger), [`push_url:`](#push_url) — plus the [swap](#swap-values), [trigger](#trigger-values), and [target](#targets) value tables
|
|
38
|
+
- [Shorthands](#shorthands)
|
|
39
|
+
|
|
40
|
+
## Attributes
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
attribute :status, default: "active"
|
|
44
|
+
attribute :page, default: 1
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Attributes are a component's *wire state* — the values that identify what this particular instance shows, small enough to travel in a URL. When the component renders inside a page, attributes come from the rendering call (`orders_panel(status: "shipped")`); when it renders over the wire — a refresh, an action, an SSE push — they come from request parameters. Either way, `build` and action callables see the same resolved values.
|
|
48
|
+
|
|
49
|
+
Wire values arrive as strings, so Weft coerces them based on each attribute's default: an `Integer` default coerces with `to_i`, a `Float` with `to_f`, and a `true`/`false` default maps `"true"` and `"1"` to `true` (anything else to `false`). Attributes with other defaults (strings, `nil`) pass through untouched. A `type:` kwarg is accepted on `attribute` but reserved for future use — today, the default *is* the type declaration.
|
|
50
|
+
|
|
51
|
+
Inside the component, `attrs` returns the resolved values with method-style access:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
attrs.status # => "shipped"
|
|
55
|
+
attrs.page # => 2 (an Integer — coerced)
|
|
56
|
+
attrs[:status] # explicit hash-style access
|
|
57
|
+
attrs.to_h # the underlying hash
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Declared attribute names always win over hash methods — if you declare `attribute :count`, `attrs.count` is your value, not `Hash#count`. For anything not declared, the hash API is available directly on `attrs`.
|
|
61
|
+
|
|
62
|
+
Declaring attributes has a routing consequence: a component with attributes (or any verb below) is considered independently addressable and gets its own route. See [Routing](routing.md).
|
|
63
|
+
|
|
64
|
+
## Verbs
|
|
65
|
+
|
|
66
|
+
### `refreshes` — the client re-fetches
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
refreshes every: 10.seconds # poll on a timer
|
|
70
|
+
refreshes every: 0.6 # sub-second polling ("every 600ms")
|
|
71
|
+
refreshes on: "order-updated" # re-fetch when an event fires
|
|
72
|
+
refreshes every: 30, on: "saved" # both
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The component's wrapper element gets the htmx wiring to GET its own route and replace itself with the response (`outerHTML` swap). With `every:`, that happens on a timer. With `on:`, it happens whenever the named event fires — typically emitted by some other component's `triggers` declaration, arriving as an `HX-Trigger` response header and listened for at the body level, so any component on the page can react to any other's events.
|
|
76
|
+
|
|
77
|
+
Multiple `refreshes` calls accumulate into a single trigger list. Because the wiring is declared on the class, it's present both in the initial page render *and* in every refreshed fragment — the component keeps refreshing forever, with nothing duplicated by hand.
|
|
78
|
+
|
|
79
|
+
Intervals count in seconds — an integer, a float, or an ActiveSupport duration. Whole seconds render as htmx's `every 5s`; fractional values render in millisecond syntax (`every 600ms`). One millisecond is the floor: anything smaller is rounded up to `1ms`, with a warning through `Weft.logger`.
|
|
80
|
+
|
|
81
|
+
### `pushes` — the server sends updates
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
pushes every: 5.seconds
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Where `refreshes` polls, `pushes` streams: the Router auto-generates an SSE endpoint for the component (at `<component path>/_stream` — see [Routing](routing.md)), and the component renders with the htmx SSE attributes to connect to it. On the declared interval — seconds, fractional or whole, with the same 1ms floor as `refreshes` — the server re-renders the component and pushes the result down the open connection.
|
|
88
|
+
|
|
89
|
+
A new subscriber receives an immediate snapshot frame, then the regular cadence. Pushed frames swap into the component's *interior* (`innerHTML`) — the wrapper element holds the SSE connection, so it must persist across updates.
|
|
90
|
+
|
|
91
|
+
Pages include the htmx SSE extension script automatically when any component declares `pushes` (the [`include_sse_ext`](configuration.md#include_sse_ext) setting).
|
|
92
|
+
|
|
93
|
+
### `performs` — user-initiated actions
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
performs :advance do |attrs|
|
|
97
|
+
order = Oms::Order.find(attrs.order_id)
|
|
98
|
+
Oms::AdvanceOrder.call(order)
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Declares an action: the Router generates a route for it, and elements wire to it with the `action:` kwarg (below). When the request arrives, the callable runs, then the component re-renders and the response replaces it in the page.
|
|
103
|
+
|
|
104
|
+
The full signature:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
performs :name, method: :post, swap: :outer_html, target: nil do |attrs| ... end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- **`method:`** — the HTTP method (default `:post`). A *named* action routes at `<component path>/<name>`; a *nameless* one (`performs method: :delete do ... end`) routes at the component's own path, distinguished by method. A nameless GET action is special: it intercepts the component's own render route, running the callable before every over-the-wire render.
|
|
111
|
+
- **`swap:`** — how the response lands in the DOM (default `:outer_html`, replacing the component). See the [swap table](#swap-values).
|
|
112
|
+
- **`target:`** — a CSS selector for where the response lands (default: the component itself, by DOM id).
|
|
113
|
+
|
|
114
|
+
### The callable contract
|
|
115
|
+
|
|
116
|
+
Action callables receive one argument — the component's resolved `attrs` — and their return value directs what happens next:
|
|
117
|
+
|
|
118
|
+
- **`nil`** (or any ignored value): re-render with the original attrs. The common case — the callable did its side effect; the fresh render reflects it.
|
|
119
|
+
- **a `Hash`**: merged into the attrs (returned keys win), and the merged set drives the re-render. Use this to change state on the way through: `performs :filter do |attrs| { page: 1 } end`.
|
|
120
|
+
- **a `Weft::Redirect`**: navigate away instead of re-rendering. Build one with `Weft.redirect`:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
performs :create do |attrs|
|
|
124
|
+
order = Oms::CreateOrder.call(attrs.to_h)
|
|
125
|
+
Weft.redirect(OrderDetailPage, order_id: order.id)
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`Weft.redirect` takes a `Weft::Page` subclass plus attrs (interpolated into the page's path pattern), or a plain URL string. The Router handles transport: htmx requests get an `HX-Redirect` header, traditional form submissions get a 302.
|
|
130
|
+
|
|
131
|
+
If the callable raises, the error walks the component's recovery chain — see [Error handling](error-handling.md).
|
|
132
|
+
|
|
133
|
+
### `transfers` — actions that render something else
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
transfers :edit, to: EditableOrderHeader do |attrs|
|
|
137
|
+
{ mode: "full" }
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Identical to `performs` in signature and contract, except the response renders the `to:` component instead of the declaring one — for actions whose natural result is a different piece of UI (a read-only header becoming an edit form). The merged attrs feed the target component. The target only needs to *render*; it does not need its own route (see [routability vs. render targets](routing.md#routable-vs-render-target)).
|
|
142
|
+
|
|
143
|
+
### `dismisses` — remove from the DOM
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
dismisses :close # no side effects
|
|
147
|
+
dismisses :archive do |attrs| # with side effects
|
|
148
|
+
Item.find(attrs.item_id).archive!
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Sugar for `performs` with `method: :delete, swap: :delete`: on success, the component is removed from the page entirely. The callable, if given, runs for side effects. If it raises, Weft overrides the destructive swap (via `HX-Reswap`) so the error rendering appears where the component was, rather than the element silently vanishing.
|
|
153
|
+
|
|
154
|
+
### `triggers` — announce to the rest of the page
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
triggers "delivery-completed"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Every action response from this component carries the named event in its `HX-Trigger` header. Other components subscribe with `refreshes on: "delivery-completed"` — a decoupled way to say "when this changes, those refresh," without the components knowing about each other. Multiple `triggers` declarations accumulate.
|
|
161
|
+
|
|
162
|
+
### `includes` — companions in the same response
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
includes Oms::OrderHeader # alongside every response
|
|
166
|
+
includes Oms::OrderHeader, on: :advance # only for the :advance action
|
|
167
|
+
includes Oms::OrderHeader do |attrs| # with explicit attr mapping
|
|
168
|
+
{ order_id: attrs.order_id, compact: true }
|
|
169
|
+
end
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Sometimes one interaction changes two things: completing a shipment updates the shipment card *and* the order header above it. `includes` declares that relationship — whenever this component responds to an action or pushes an SSE frame, the included component renders too, marked out-of-band (`hx-swap-oob`) so htmx routes it to its own DOM slot by id.
|
|
173
|
+
|
|
174
|
+
Without a block, the included component resolves its attributes from the same request parameters. With a block, the block receives the primary component's resolved attrs and returns the wire attrs for the included one. With `on:`, the inclusion applies only to that named action (and not to SSE pushes; unfiltered inclusions apply to both).
|
|
175
|
+
|
|
176
|
+
### `recovers` — declare error behavior
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
recovers from: Weft::Unprocessable do |attrs, error|
|
|
180
|
+
{ error_message: error.message }
|
|
181
|
+
end
|
|
182
|
+
recovers from: Weft::Unauthorized, with: LoginPage
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Declares how this component or page responds when a render or action raises. `from:` matches by exception class, HTTP status code, status range, or an array of those; `with:` names what renders instead. The gem ships default recoveries, so this is opt-in refinement. The complete model — matching, chain order, auto-injected attributes — is in [Error handling](error-handling.md).
|
|
186
|
+
|
|
187
|
+
### Other class-body declarations
|
|
188
|
+
|
|
189
|
+
**`adds_children_to :@ivar`** — generates the standard container pattern: children added from a caller's block go into the named element rather than the wrapper, while the component's own structural elements (built during `build`) land normally.
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
class Card < Weft::Component
|
|
193
|
+
adds_children_to :@body
|
|
194
|
+
|
|
195
|
+
def build(attributes = {})
|
|
196
|
+
super
|
|
197
|
+
h2 "Header" # structural — goes to the wrapper
|
|
198
|
+
@body = div(class: "card-body") # caller's block content goes here
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The leading `@` in the symbol is required, as a reminder that *you* must assign that instance variable somewhere in `build` — if `build` finishes without assigning it and a child then arrives, Weft raises a pointed error rather than silently misplacing content. The underlying mechanics (and when to hand-roll instead) are in [Arbre: the HTML layer](arbre.md#receiving-caller-content).
|
|
204
|
+
|
|
205
|
+
**`abstract!` / `routable!`** — override the class's routing eligibility in either direction. Covered in [Routing](routing.md#abstract-and-routable).
|
|
206
|
+
|
|
207
|
+
## Element kwargs
|
|
208
|
+
|
|
209
|
+
Inside `build` (and inside blocks nested under it), any element accepts Weft kwargs alongside its normal HTML attributes. Weft intercepts them at render time and expands them into htmx wiring.
|
|
210
|
+
|
|
211
|
+
### `action:`
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
button "Advance", action: :advance, class: "btn btn-primary"
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Wires the element to a declared `performs`/`transfers` action on the nearest enclosing component that declares it. Expands to the full htmx set: the request (`hx-post` etc. to the action's route), the target (the component's own element, unless the action declared `target:`), the swap, and the component's current attrs as the payload (`hx-vals`).
|
|
218
|
+
|
|
219
|
+
On a `form` element, `action:` additionally emits plain HTML `action` and `method` attributes, so the form still submits without JavaScript — and the field values themselves become the payload:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
form(action: :create) do
|
|
223
|
+
input(type: "text", name: "customer_name")
|
|
224
|
+
input(type: "submit", value: "Create")
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### `navigate:`
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
button "Next", navigate: { page: attrs.page + 1 }
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Re-fetches the enclosing component with some of its attrs changed — a GET to the component's own route with the overridden values, replacing the component. This is the idiom for filters, sorting, and pagination: same component, different wire state. Pass `nil` to drop an attr from the URL. Pairs naturally with `push_url:` when the new state should be reflected in the address bar.
|
|
235
|
+
|
|
236
|
+
### `loads:`
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
button "Show manifest", loads: Logistics::ShipmentManifest,
|
|
240
|
+
with: { shipment_id: shipment.id },
|
|
241
|
+
swap: :fill, target: "#detail-pane"
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Loads a *different* component into a chosen DOM location on click (or whatever `trigger:` you add). `swap:` and `target:` are required — `loads:` is the fully-explicit primitive underneath the [shorthands](#shorthands), which exist to fill those in for common patterns. `with:` supplies the target component's wire attrs; omitted, it defaults to the enclosing component's current attrs.
|
|
245
|
+
|
|
246
|
+
### `trigger:`
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
div(loads: Preview, with: { id: id }, swap: :fill, target: :self,
|
|
250
|
+
trigger: :visible)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Sets when the element's request fires. Accepts the semantic symbols in the [trigger table](#trigger-values) or any raw [htmx trigger string](https://htmx.org/attributes/hx-trigger/) for full control (`"mouseenter once from:closest .card"`). Works standalone or alongside `action:` / `navigate:` / `loads:` / a shorthand.
|
|
254
|
+
|
|
255
|
+
### `push_url:`
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
button label, action: :filter, push_url: "/orders?status=#{status}"
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Pushes a URL into the browser's address bar when the request completes, keeping the location shareable and the back button meaningful. Pass the URL string, or `true` to push the request's own URL.
|
|
262
|
+
|
|
263
|
+
### Swap values
|
|
264
|
+
|
|
265
|
+
Weft accepts semantic swap names (preferred), the htmx-native names as symbols, or any raw string:
|
|
266
|
+
|
|
267
|
+
| Semantic | htmx equivalent | Effect |
|
|
268
|
+
| --- | --- | --- |
|
|
269
|
+
| `:replace` | `outerHTML` | Replace the target element entirely |
|
|
270
|
+
| `:fill` | `innerHTML` | Replace the target's contents |
|
|
271
|
+
| `:before` | `beforebegin` | Insert before the target |
|
|
272
|
+
| `:prepend` | `afterbegin` | Insert at the start of the target |
|
|
273
|
+
| `:append` | `beforeend` | Insert at the end of the target |
|
|
274
|
+
| `:after` | `afterend` | Insert after the target |
|
|
275
|
+
| `:remove` | `delete` | Remove the target |
|
|
276
|
+
| `:none` | `none` | Don't swap anything |
|
|
277
|
+
|
|
278
|
+
### Trigger values
|
|
279
|
+
|
|
280
|
+
| Semantic | htmx equivalent | Fires… |
|
|
281
|
+
| --- | --- | --- |
|
|
282
|
+
| `:click` | `click` | on click |
|
|
283
|
+
| `:hover` | `mouseenter once` | on first hover |
|
|
284
|
+
| `:visible` | `revealed` | when scrolled into view |
|
|
285
|
+
| `:input` | `input changed delay:300ms` | as the user types, debounced |
|
|
286
|
+
|
|
287
|
+
### Targets
|
|
288
|
+
|
|
289
|
+
Wherever a `target:` is accepted: `:self` targets the element itself, a string is a CSS selector passed through to htmx (including forms like `"closest tr"`), and an Arbre element reference targets that element by its id. In verb declarations (`performs`/`transfers`), only the selector-string form applies — `:self` and element references describe elements, which don't exist yet at class-declaration time.
|
|
290
|
+
|
|
291
|
+
## Shorthands
|
|
292
|
+
|
|
293
|
+
Shorthands are named presets over the `loads:` machinery — one kwarg that says what the interaction *is*, with the trigger and swap details baked in:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
button "▸", inline_expand: Oms::OrderInlineDetail,
|
|
297
|
+
with: { order_id: order.id },
|
|
298
|
+
target: "closest tr"
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
The kwarg's value is the component class to load (`with:` supplies its attrs, same as `loads:`). The gem ships these presets:
|
|
302
|
+
|
|
303
|
+
| Shorthand | Trigger | Swap | Target | Example |
|
|
304
|
+
| --- | --- | --- | --- | --- |
|
|
305
|
+
| `tooltip:` | `:hover` | `:fill` | supply `target:` | [Tooltip](examples/tooltip.md) |
|
|
306
|
+
| `inline_expand:` | `:click` | `:after` | supply `target:` | [Inline Expansion](examples/inline-expansion.md) |
|
|
307
|
+
| `lazy:` | `:visible` | `:fill` | `:self` | [Lazy Loading](examples/lazy-loading.md) |
|
|
308
|
+
| `modal:` | `:click` | `:fill` | supply `target:` | [Modal Dialog](examples/modal-dialog.md) |
|
|
309
|
+
| `load_more:` | `:click` | `:replace` | `:self` | [Click to Load](examples/click-to-load.md) |
|
|
310
|
+
| `infinite_scroll:` | `:visible` | `:after` | supply `target:` | [Infinite Scroll](examples/infinite-scroll.md) |
|
|
311
|
+
| `live_search:` | `:input` | `:fill` | supply `target:` | [Active Search](examples/active-search.md) |
|
|
312
|
+
| `tabs:` | `:click` | `:fill` | supply `target:` | [Tabs](examples/tabs.md) |
|
|
313
|
+
| `retry:` | `:click` | `:replace` | `closest .weft-error` | — |
|
|
314
|
+
|
|
315
|
+
Where the table says "supply `target:`", the preset has no universally-right answer for where the content lands, so the call site provides it (omitting it raises immediately, with a message saying so). Explicit `swap:` and `target:` kwargs always override the preset.
|
|
316
|
+
|
|
317
|
+
`retry:` is the odd one out: its value is a **URL string** rather than a component class — the failing component's own GET URL, as injected into error components via the `:retry_url` recovery attribute (see [Error handling](error-handling.md)). Its baked-in target replaces the enclosing `.weft-error` box with the freshly-rendered component:
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
button "Retry", retry: attrs.retry_url
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Registering your own
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
Weft.register_shorthand :paginate, trigger: :click, swap: :replace
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
A registration names the preset and provides any of `trigger:`, `swap:`, and `target:`. From then on, `paginate:` works as an element kwarg everywhere — same machinery, your vocabulary. Naming interactions after their intent keeps call sites readable: `button "Next", paginate: OrdersPanel, with: { page: 2 }` says more than the four htmx attributes it expands to.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Error handling
|
|
2
|
+
|
|
3
|
+
In a component-oriented UI, an error is part of the interface. When one component's render or action raises, the right outcome is usually a visible error state *in that component's place* — not a dead button, not a blank region, and certainly not a whole-page crash. Weft's error handling is built around that idea: exceptions map to renderable fallbacks through a declarative chain, with sensible defaults at every level, so an unhandled error always lands somewhere visible.
|
|
4
|
+
|
|
5
|
+
Two layers cooperate to make this work. On the server, the Router catches errors and renders a recovery target instead. On the client, `Weft::Page` configures htmx to swap error responses into the page (by default htmx discards them) — which is why a failing component shows its error box right where the component was.
|
|
6
|
+
|
|
7
|
+
**In this document:**
|
|
8
|
+
|
|
9
|
+
- [The error classes](#the-error-classes)
|
|
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
|
+
- [Auto-injected recovery attributes](#auto-injected-recovery-attributes)
|
|
13
|
+
- [Presentation settings](#presentation-settings)
|
|
14
|
+
|
|
15
|
+
## The error classes
|
|
16
|
+
|
|
17
|
+
Weft ships a small semantic hierarchy rooted at `Weft::Error`:
|
|
18
|
+
|
|
19
|
+
| Class | Status | Meaning |
|
|
20
|
+
| --- | --- | --- |
|
|
21
|
+
| `Weft::Error` | — | Abstract root. Never raised directly; `rescue Weft::Error` catches the whole family. |
|
|
22
|
+
| `Weft::HTTPError` | — | Abstract intermediate for errors that carry an HTTP status. |
|
|
23
|
+
| `Weft::NotFound` | 404 | The thing addressed doesn't exist. |
|
|
24
|
+
| `Weft::Unauthorized` | 401 | Authentication required. |
|
|
25
|
+
| `Weft::Forbidden` | 403 | Authenticated, but not allowed. |
|
|
26
|
+
| `Weft::Unprocessable` | 422 | The request was understood but can't be acted on — validation failures, mostly. |
|
|
27
|
+
| `Weft::InternalError` | 500 | An explicit "we broke" signal. |
|
|
28
|
+
|
|
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. Errors that aren't `Weft::HTTPError`s — an unrescued `ActiveRecord::RecordNotFound`, a `NoMethodError` — are treated as status 500.
|
|
30
|
+
|
|
31
|
+
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
|
+
## The `recovers` chain
|
|
34
|
+
|
|
35
|
+
Components and pages declare how they handle errors with `recovers`:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
class OrderEditor < Weft::Component
|
|
39
|
+
recovers from: Weft::Unprocessable do |attrs, error|
|
|
40
|
+
{ error_message: error.message }
|
|
41
|
+
end
|
|
42
|
+
recovers from: Weft::Unauthorized, with: LoginPage
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Each declaration is an edge: *when this kind of error escapes me, render that instead.* The pieces:
|
|
47
|
+
|
|
48
|
+
**`from:`** decides whether an edge matches a given exception. It accepts:
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
- a **Class** — matches that exception class and its subclasses (`from: Weft::HTTPError` catches the whole status-bearing family);
|
|
52
|
+
- an **Integer** — matches by HTTP status (`from: 404`);
|
|
53
|
+
- a **Range** — matches statuses in the range (`from: 500..599`);
|
|
54
|
+
- an **Array** of any of the above — matches if any element does.
|
|
55
|
+
|
|
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 attrs.
|
|
57
|
+
|
|
58
|
+
**The block**, if given, receives `(attrs, error)` — the same resolved attrs an action callable sees, plus the exception — and returns a hash merged into the attrs 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.
|
|
59
|
+
|
|
60
|
+
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
|
+
|
|
62
|
+
### The built-in edges
|
|
63
|
+
|
|
64
|
+
`Weft::Component` and `Weft::Page` each ship two edges, which is why error handling works before you've declared anything:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
# on Weft::Component
|
|
68
|
+
recovers from: Weft::NotFound, with: :not_found_component
|
|
69
|
+
recovers from: StandardError, with: :error_component
|
|
70
|
+
|
|
71
|
+
# on Weft::Page
|
|
72
|
+
recovers from: Weft::NotFound, with: :not_found_page
|
|
73
|
+
recovers from: StandardError, with: :error_page
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The symbols resolve through `Weft.configuration`, so [reassigning those knobs](configuration.md#the-four-fallback-targets) rebrands the defaults app-wide. Because these live on the base classes, any edge you declare on your own class takes precedence.
|
|
77
|
+
|
|
78
|
+
## What happens when something raises
|
|
79
|
+
|
|
80
|
+
**In component context** — a fragment render, an action, an SSE frame — 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
|
+
|
|
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.
|
|
83
|
+
|
|
84
|
+
**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
|
+
|
|
86
|
+
**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
|
+
|
|
88
|
+
Errors during SSE pushes don't kill the stream: the frame is skipped, the error logged, and pushing resumes on the next interval.
|
|
89
|
+
|
|
90
|
+
## Auto-injected recovery attributes
|
|
91
|
+
|
|
92
|
+
A recovery target usually wants context: what failed, where, with what status. The Router offers five values, injected **schema-gated**: each is passed only if the target *declares an attribute of that name*. Declaring the attribute is the opt-in; anything not declared is never injected, so nothing leaks into renders (or URLs) uninvited.
|
|
93
|
+
|
|
94
|
+
| Attribute | Value |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| `:exception` | The exception object itself. |
|
|
97
|
+
| `:request_path` | The path of the failing request. |
|
|
98
|
+
| `:status_code` | The resolved HTTP status (the exception's, or 500). |
|
|
99
|
+
| `:component_id` | The failing component's DOM id. |
|
|
100
|
+
| `:retry_url` | A GET URL that re-renders the failing component with its current attrs. |
|
|
101
|
+
|
|
102
|
+
So a custom error component opts in by declaration:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
class MyApp::ErrorComponent < Weft::Component
|
|
106
|
+
abstract!
|
|
107
|
+
|
|
108
|
+
attribute :exception
|
|
109
|
+
attribute :retry_url
|
|
110
|
+
|
|
111
|
+
def build(attributes = {})
|
|
112
|
+
super
|
|
113
|
+
add_class "weft-error"
|
|
114
|
+
div { text_node "Something went wrong." }
|
|
115
|
+
div @attrs.exception.message if Weft.configuration.verbose_error_pages
|
|
116
|
+
button "Retry", retry: @attrs.retry_url if @attrs.retry_url
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Notes on the individual values:
|
|
122
|
+
|
|
123
|
+
- **These five names are reserved** on any class used as a recovery target. Declaring an attribute 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.
|
|
124
|
+
- **`:component_id`** preserves DOM identity: render your error wrapper with it as the element id (the gem's defaults do) and the error lands under the failing component's original id — so multiple simultaneous failures each swap into their own slot rather than colliding.
|
|
125
|
+
- **`:retry_url`** feeds the [`retry:` shorthand](dsl.md#shorthands): 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.
|
|
126
|
+
- 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:` shorthand targets, and a useful styling hook besides.
|
|
128
|
+
|
|
129
|
+
## Presentation settings
|
|
130
|
+
|
|
131
|
+
Two configuration settings shape how the built-in fallbacks present; both are covered in detail in [Configuration](configuration.md#error-handling):
|
|
132
|
+
|
|
133
|
+
- [`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
|
+
- [`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.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
If you've ever thought *"this button should be right here, and pressing it should just do the thing"* — that's the instinct Weft is built around. Each example on these pages starts from something a user should be able to do, and shows the component that does it: the markup, the behavior, and the server-side logic in one place, because in Weft they *are* one place.
|
|
4
|
+
|
|
5
|
+
Every example is complete and self-contained — a small data stub stands in for your real data layer, and the code shown is the code that ran. The "On the wire" sections quote real captured requests and responses, so you can see exactly what travels.
|
|
6
|
+
|
|
7
|
+
## The catalog
|
|
8
|
+
|
|
9
|
+
| Example | What it shows |
|
|
10
|
+
| --- | --- |
|
|
11
|
+
| [Click to Edit](click-to-edit.md) | Swap a read-only view for an edit form in place — `loads:` + `transfers` |
|
|
12
|
+
| [Edit Row](edit-row.md) | The same pattern per table row |
|
|
13
|
+
| [Delete Row](delete-row.md) | Remove a row with a confirmation — `dismisses` |
|
|
14
|
+
| [Bulk Update](bulk-update.md) | One form updating many rows — `performs` + array params |
|
|
15
|
+
| [Inline Validation](inline-validation.md) | Per-field validation as the user types — `performs` + `recovers` |
|
|
16
|
+
| [File Upload](file-upload.md) | Multipart upload through a component action |
|
|
17
|
+
| [Reset User Input](reset-user-input.md) | Clearing a form after submit — free in Weft |
|
|
18
|
+
| [Click to Load](click-to-load.md) | Load the next page of rows on demand — `load_more:` |
|
|
19
|
+
| [Lazy Loading](lazy-loading.md) | Defer expensive content until it's visible — `lazy:` |
|
|
20
|
+
| [Infinite Scroll](infinite-scroll.md) | Rows that keep coming as you scroll — `infinite_scroll:` |
|
|
21
|
+
| [Inline Expansion](inline-expansion.md) | Expand a row's detail in place — `inline_expand:` |
|
|
22
|
+
| [Active Search](active-search.md) | Search-as-you-type — `live_search:` |
|
|
23
|
+
| [Value Select](value-select.md) | Cascading selects — one select repopulating another |
|
|
24
|
+
| [Tabs](tabs.md) | Server-driven tab panes — `tabs:` |
|
|
25
|
+
| [Tooltip](tooltip.md) | Lazy-loaded hover detail — `tooltip:` |
|
|
26
|
+
| [Modal Dialog](modal-dialog.md) | Open a modal, close it, no JavaScript — `modal:` + `dismisses` |
|
|
27
|
+
| [Browser Dialogs](browser-dialogs.md) | Native confirm/prompt guards on actions |
|
|
28
|
+
| [Keyboard Shortcuts](keyboard-shortcuts.md) | Key-driven actions via `trigger:` |
|
|
29
|
+
| [Progress Bar](progress-bar.md) | A job-runner progress bar — `refreshes every:` |
|
|
30
|
+
| [Live Ticker](live-ticker.md) | Server-pushed updates over SSE — `pushes every:` |
|
|
31
|
+
| [Updating Other Content](updating-other-content.md) | One action updating several regions — `includes` + `triggers` |
|
|
32
|
+
|
|
33
|
+
## Coming from htmx?
|
|
34
|
+
|
|
35
|
+
This catalog deliberately covers the ground of [htmx's examples](https://htmx.org/examples/) — if you know a pattern from there, its Weft answer is here. Three of the pages above (Tooltip, Inline Expansion, Live Ticker) have no htmx counterpart; everything in htmx's catalog maps as follows:
|
|
36
|
+
|
|
37
|
+
| htmx example | Weft's answer |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| Click To Edit | [Click to Edit](click-to-edit.md) |
|
|
40
|
+
| Bulk Update | [Bulk Update](bulk-update.md) |
|
|
41
|
+
| Click To Load | [Click to Load](click-to-load.md) |
|
|
42
|
+
| Delete Row | [Delete Row](delete-row.md) |
|
|
43
|
+
| Edit Row | [Edit Row](edit-row.md) |
|
|
44
|
+
| Lazy Loading | [Lazy Loading](lazy-loading.md) |
|
|
45
|
+
| Inline Validation | [Inline Validation](inline-validation.md) |
|
|
46
|
+
| Infinite Scroll | [Infinite Scroll](infinite-scroll.md) |
|
|
47
|
+
| Active Search | [Active Search](active-search.md) |
|
|
48
|
+
| Progress Bar | [Progress Bar](progress-bar.md) |
|
|
49
|
+
| Value Select | [Value Select](value-select.md) |
|
|
50
|
+
| File Upload | [File Upload](file-upload.md) — the upload itself; the JS-driven progress meter is out of scope |
|
|
51
|
+
| Preserving File Inputs | not ported — a browser constraint htmx works around with custom JS; re-select files after a failed submit |
|
|
52
|
+
| Reset User Input | [Reset User Input](reset-user-input.md) |
|
|
53
|
+
| Dialogs — Browser | [Browser Dialogs](browser-dialogs.md) |
|
|
54
|
+
| Dialogs — UIKit | see [Modal Dialog](modal-dialog.md); CSS-framework integrations are future work |
|
|
55
|
+
| Dialogs — Bootstrap | same |
|
|
56
|
+
| Dialogs — Custom | [Modal Dialog](modal-dialog.md) — and Weft's needs no hyperscript |
|
|
57
|
+
| Tabs (HATEOAS) | [Tabs](tabs.md) |
|
|
58
|
+
| Tabs (JavaScript) | [Tabs](tabs.md) — the server-driven variant *is* the Weft way |
|
|
59
|
+
| Keyboard Shortcuts | [Keyboard Shortcuts](keyboard-shortcuts.md) |
|
|
60
|
+
| Sortable (drag & drop) | not ported — requires Sortable.js; client-side JS integration is outside this catalog's scope |
|
|
61
|
+
| Updating Other Content | [Updating Other Content](updating-other-content.md) — declarative, where htmx offers four manual options |
|
|
62
|
+
| Confirm (custom dialog) | not ported — requires sweetalert2; the no-JS answer is [Browser Dialogs](browser-dialogs.md) |
|
|
63
|
+
| Async Authentication | not ported — client-side token handling, outside this catalog's scope |
|
|
64
|
+
| Web Components | not ported — shadow-DOM integration, outside this catalog's scope |
|
|
65
|
+
| Animations | not ported yet — htmx's swap/settle CSS classes work unchanged under Weft |
|
|
66
|
+
| moveBefore() | not ported — experimental browser API |
|
|
67
|
+
|
|
68
|
+
## Where these fit
|
|
69
|
+
|
|
70
|
+
The examples show *patterns*; the mechanics behind them live in the reference docs — [the DSL](../dsl.md) for every verb and kwarg, [Routing](../routing.md) for how components get their URLs, [Error handling](../error-handling.md) for the recovery machinery, and [Arbre](../arbre.md) for the HTML layer itself. New to Weft entirely? Start with [the tutorial](../tutorial.md).
|