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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Active Search
|
|
2
|
+
|
|
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
|
+
|
|
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:` shorthand 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
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
PEOPLE = [
|
|
11
|
+
{ name: "Venus Grimes", email: "vgrimes@example.org" },
|
|
12
|
+
{ name: "Kandy Kane", email: "kandy@example.org" },
|
|
13
|
+
{ name: "Antonia Benitez", email: "abenitez@example.org" },
|
|
14
|
+
{ name: "Dewayne Sharp", email: "dsharp@example.org" },
|
|
15
|
+
{ name: "Nathan Grimes", email: "ngrimes@example.org" },
|
|
16
|
+
{ name: "Mia Chandler", email: "mchandler@example.org" },
|
|
17
|
+
{ name: "Leland Ortega", email: "lortega@example.org" },
|
|
18
|
+
{ name: "Saanvi Rao", email: "srao@example.org" }
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
class ContactResults < Weft::Component
|
|
22
|
+
builder_method :contact_results
|
|
23
|
+
|
|
24
|
+
attribute :q, default: ""
|
|
25
|
+
|
|
26
|
+
def build(attributes = {})
|
|
27
|
+
super
|
|
28
|
+
matches = search(attrs.q)
|
|
29
|
+
if matches.empty?
|
|
30
|
+
para "No one matches “#{attrs.q}”."
|
|
31
|
+
else
|
|
32
|
+
table do
|
|
33
|
+
thead { tr { th "Name"; th "Email" } }
|
|
34
|
+
tbody do
|
|
35
|
+
matches.each { |person| tr { td person[:name]; td person[:email] } }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def search(query)
|
|
44
|
+
q = query.strip.downcase
|
|
45
|
+
PEOPLE.select { |person| "#{person[:name]} #{person[:email]}".downcase.include?(q) }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class ContactSearch < Weft::Component
|
|
50
|
+
builder_method :contact_search
|
|
51
|
+
|
|
52
|
+
def build(attributes = {})
|
|
53
|
+
super
|
|
54
|
+
h3 "Search the directory"
|
|
55
|
+
input type: "search", name: "q", placeholder: "Begin typing to search...",
|
|
56
|
+
live_search: ContactResults, target: "#search-results"
|
|
57
|
+
div id: "search-results" do
|
|
58
|
+
contact_results(q: "")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
(The `PEOPLE` array stands in for your data layer — in a real app, `search` becomes a scoped query.)
|
|
65
|
+
|
|
66
|
+
## How it works
|
|
67
|
+
|
|
68
|
+
**The input's `name` is the search parameter.** [`live_search:`](../dsl.md#shorthands) 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 `attribute :q` to receive it. An empty default keeps the blank-box case (matching everyone) working.
|
|
69
|
+
|
|
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
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
input type: "search", name: "q",
|
|
74
|
+
live_search: ContactResults, target: "#search-results",
|
|
75
|
+
trigger: "keyup changed delay:500ms"
|
|
76
|
+
```
|
|
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(q: "")` in the container means the page starts with the full directory rather than an empty pane — the initial state and every subsequent state are the same component.
|
|
79
|
+
|
|
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
|
+
|
|
82
|
+
## On the wire
|
|
83
|
+
|
|
84
|
+
The initial render — the wired input, and the container holding the unfiltered component:
|
|
85
|
+
|
|
86
|
+
```html
|
|
87
|
+
<input type="search" name="q" placeholder="Begin typing to search..."
|
|
88
|
+
hx-get="/_components/contact_results" hx-swap="innerHTML"
|
|
89
|
+
hx-target="#search-results" hx-trigger="input changed delay:300ms"/>
|
|
90
|
+
<div id="search-results">
|
|
91
|
+
<div id="contact-results-">
|
|
92
|
+
<table>…all eight people…</table>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
(That `id="contact-results-"` is the component's DOM id — dasherized class name plus first attribute value, which here is the empty string.)
|
|
98
|
+
|
|
99
|
+
Typing "grimes" settles into `GET /_components/contact_results?q=grimes`:
|
|
100
|
+
|
|
101
|
+
```html
|
|
102
|
+
<div id="contact-results-grimes">
|
|
103
|
+
<table>
|
|
104
|
+
<thead><tr><th>Name</th><th>Email</th></tr></thead>
|
|
105
|
+
<tbody>
|
|
106
|
+
<tr><td>Venus Grimes</td><td>vgrimes@example.org</td></tr>
|
|
107
|
+
<tr><td>Nathan Grimes</td><td>ngrimes@example.org</td></tr>
|
|
108
|
+
</tbody>
|
|
109
|
+
</table>
|
|
110
|
+
</div>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
And a query with no matches (`GET /_components/contact_results?q=zz`) returns the friendly empty state:
|
|
114
|
+
|
|
115
|
+
```html
|
|
116
|
+
<div id="contact-results-zz">
|
|
117
|
+
<p>No one matches “zz”.</p>
|
|
118
|
+
</div>
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Related
|
|
122
|
+
|
|
123
|
+
- [Tabs](tabs.md) — the same fill-a-stable-container shape, driven by clicks instead of keystrokes.
|
|
124
|
+
- The [shorthands table](../dsl.md#shorthands) 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 attributes in depth.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Browser Dialogs
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
This is Weft's take on [htmx's dialogs example](https://htmx.org/examples/dialogs/). The honest point of this page is that Weft has no kwarg for `hx-confirm` — and doesn't need one: raw htmx attributes pass through to the element untouched, side by side with whatever the Weft kwargs expand to.
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
ACCOUNT = { status: "active" }
|
|
11
|
+
|
|
12
|
+
class AccountPanel < Weft::Component
|
|
13
|
+
builder_method :account_panel
|
|
14
|
+
|
|
15
|
+
performs :deactivate do
|
|
16
|
+
ACCOUNT[:status] = "deactivated"
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def build(attributes = {})
|
|
21
|
+
super
|
|
22
|
+
para "Your account is #{ACCOUNT[:status]}."
|
|
23
|
+
if ACCOUNT[:status] == "active"
|
|
24
|
+
button "Deactivate my account", action: :deactivate,
|
|
25
|
+
"hx-confirm" => "Deactivate your account? You can sign back in to reactivate."
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
(The `ACCOUNT` hash stands in for your data layer, and the current user for whoever your app has signed in.)
|
|
32
|
+
|
|
33
|
+
## How it works
|
|
34
|
+
|
|
35
|
+
**Raw htmx attributes ride along.** Weft intercepts only its own kwargs — `action:`, `trigger:`, and friends. Everything else on an element, string-keyed htmx attributes included, renders as a plain HTML attribute. So `"hx-confirm" => "..."` lands verbatim next to the wiring that [`action:`](../dsl.md#action) expanded, and htmx picks it up like any hand-written page. This is the general escape hatch: whenever htmx has a feature Weft has no vocabulary for, write the attribute yourself.
|
|
36
|
+
|
|
37
|
+
**The guard lives in the browser, not on the server.** `hx-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
|
+
|
|
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
|
+
|
|
41
|
+
**`hx-prompt` doesn't carry over.** 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 attributes, which come from request parameters, so the prompted value never reaches your code. When an action needs user input, give the component a real input: a form field paired with a declared attribute, as in [Click to Edit](click-to-edit.md).
|
|
42
|
+
|
|
43
|
+
## On the wire
|
|
44
|
+
|
|
45
|
+
The initial render — the confirm attribute sits verbatim beside the expanded action wiring:
|
|
46
|
+
|
|
47
|
+
```html
|
|
48
|
+
<div id="account-panel">
|
|
49
|
+
<p>Your account is active.</p>
|
|
50
|
+
<button hx-confirm="Deactivate your account? You can sign back in to reactivate."
|
|
51
|
+
hx-post="/_components/account_panel/deactivate"
|
|
52
|
+
hx-target="#account-panel" hx-swap="outerHTML"
|
|
53
|
+
hx-vals="{}">Deactivate my account</button>
|
|
54
|
+
</div>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Accepting the dialog issues `POST /_components/account_panel/deactivate`, and the re-rendered panel replaces the old one:
|
|
58
|
+
|
|
59
|
+
```html
|
|
60
|
+
<div id="account-panel">
|
|
61
|
+
<p>Your account is deactivated.</p>
|
|
62
|
+
</div>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The same POST sent from outside the browser — no htmx, no dialog — is accepted just the same, which is exactly why the confirm is a courtesy and the authorization is your job.
|
|
66
|
+
|
|
67
|
+
## Related
|
|
68
|
+
|
|
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 attributes: the Weft answer to "prompt the user for a value."
|
|
71
|
+
- [`action:`](../dsl.md#action) and [`performs`](../dsl.md#performs--user-initiated-actions) in the DSL reference.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Bulk Update
|
|
2
|
+
|
|
3
|
+
A table of team members, each row with an Activate checkbox; one submit applies every change at once, and a status line reports how many members were activated and deactivated.
|
|
4
|
+
|
|
5
|
+
This is Weft's take on [htmx's bulk-update example](https://htmx.org/examples/bulk-update/), with one structural difference. Their form posts the checkboxes and swaps a toast message into a separate slot, leaving the table as the user left it; in Weft the whole roster is one component, so the submit re-renders it from server state — the checkboxes come back showing what was actually saved, with the status line beneath them.
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
TEAM_MEMBERS = {
|
|
11
|
+
"1" => { name: "Joe Smith", email: "joe@smith.org", active: true },
|
|
12
|
+
"2" => { name: "Angie MacDowell", email: "angie@macdowell.org", active: true },
|
|
13
|
+
"3" => { name: "Fuqua Tarkenton", email: "fuqua@tarkenton.org", active: true },
|
|
14
|
+
"4" => { name: "Kim Yee", email: "kim@yee.org", active: false }
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
class MemberRoster < Weft::Component
|
|
18
|
+
builder_method :member_roster
|
|
19
|
+
|
|
20
|
+
attribute :active_ids, default: []
|
|
21
|
+
attribute :status
|
|
22
|
+
|
|
23
|
+
performs :update, target: "#member-roster" do |attrs|
|
|
24
|
+
checked = attrs.active_ids
|
|
25
|
+
activated = deactivated = 0
|
|
26
|
+
TEAM_MEMBERS.each do |id, member|
|
|
27
|
+
active = checked.include?(id)
|
|
28
|
+
activated += 1 if active && !member[:active]
|
|
29
|
+
deactivated += 1 if !active && member[:active]
|
|
30
|
+
member[:active] = active
|
|
31
|
+
end
|
|
32
|
+
{ status: "Activated #{activated} and deactivated #{deactivated} members." }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def build(attributes = {})
|
|
36
|
+
super
|
|
37
|
+
set_attribute :id, "member-roster"
|
|
38
|
+
form(action: :update) do
|
|
39
|
+
table do
|
|
40
|
+
thead { tr { th "Name"; th "Email"; th "Active" } }
|
|
41
|
+
tbody do
|
|
42
|
+
TEAM_MEMBERS.each do |id, member|
|
|
43
|
+
tr do
|
|
44
|
+
td member[:name]
|
|
45
|
+
td member[:email]
|
|
46
|
+
td do
|
|
47
|
+
if member[:active]
|
|
48
|
+
input type: "checkbox", name: "active_ids[]", value: id, checked: "checked"
|
|
49
|
+
else
|
|
50
|
+
input type: "checkbox", name: "active_ids[]", value: id
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
input type: "submit", value: "Bulk Update"
|
|
58
|
+
end
|
|
59
|
+
para attrs.status if attrs.status
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
(The `TEAM_MEMBERS` hash stands in for your data layer — swap in ActiveRecord or whatever your app uses.)
|
|
65
|
+
|
|
66
|
+
## How it works
|
|
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` attribute receives `["1", "2", "4"]`, and the callable reads it as `attrs.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
|
+
|
|
70
|
+
**The `default: []` is load-bearing.** When *no* boxes are checked, the browser sends no `active_ids` parameter at all, and the attribute 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
|
+
|
|
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 attrs 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 — `attrs.status` is `nil` on a fresh render and the paragraph only appears after an update.
|
|
73
|
+
|
|
74
|
+
**An array can't anchor a DOM id.** Weft derives a component's DOM id from its first declared attribute — 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
|
+
|
|
76
|
+
**The checkboxes tell the truth after the write.** `build` renders each checkbox from the data store, not from the submitted attrs — 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
|
+
|
|
78
|
+
## On the wire
|
|
79
|
+
|
|
80
|
+
The initial render (or `GET /_components/member_roster`) — the form wired to the action, one row per member:
|
|
81
|
+
|
|
82
|
+
```html
|
|
83
|
+
<div id="member-roster">
|
|
84
|
+
<form hx-post="/_components/member_roster/update" hx-target="#member-roster"
|
|
85
|
+
hx-swap="outerHTML" action="/_components/member_roster/update" method="post">
|
|
86
|
+
<table>
|
|
87
|
+
<thead>...</thead>
|
|
88
|
+
<tbody>
|
|
89
|
+
<tr>
|
|
90
|
+
<td>Joe Smith</td>
|
|
91
|
+
<td>joe@smith.org</td>
|
|
92
|
+
<td><input type="checkbox" name="active_ids[]" value="1" checked="checked"/></td>
|
|
93
|
+
</tr>
|
|
94
|
+
...
|
|
95
|
+
<tr>
|
|
96
|
+
<td>Kim Yee</td>
|
|
97
|
+
<td>kim@yee.org</td>
|
|
98
|
+
<td><input type="checkbox" name="active_ids[]" value="4"/></td>
|
|
99
|
+
</tr>
|
|
100
|
+
</tbody>
|
|
101
|
+
</table>
|
|
102
|
+
<input type="submit" value="Bulk Update"/>
|
|
103
|
+
</form>
|
|
104
|
+
</div>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Unchecking Fuqua, checking Kim, and submitting sends `POST /_components/member_roster/update` with the body `active_ids[]=1&active_ids[]=2&active_ids[]=4`. The response is the same component, re-rendered from the updated store — Fuqua's box now unchecked, Kim's checked — ending with:
|
|
108
|
+
|
|
109
|
+
```html
|
|
110
|
+
<input type="submit" value="Bulk Update"/>
|
|
111
|
+
</form>
|
|
112
|
+
<p>Activated 1 and deactivated 1 members.</p>
|
|
113
|
+
</div>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Submitting with every box unchecked sends an empty body; the default kicks in and the response reports `Activated 0 and deactivated 3 members.`
|
|
117
|
+
|
|
118
|
+
## Related
|
|
119
|
+
|
|
120
|
+
- [Click to Edit](click-to-edit.md) — the basics of pairing form fields with declared attributes.
|
|
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
|
+
- [`performs`](../dsl.md#performs--user-initiated-actions) and [the callable contract](../dsl.md#the-callable-contract) in the DSL reference.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Click to Edit
|
|
2
|
+
|
|
3
|
+
A read-only view of a record with an Edit button. Clicking swaps an editable form into its place; saving — or canceling — swaps the read-only view back. No page navigation, and no JavaScript beyond what Weft already ships.
|
|
4
|
+
|
|
5
|
+
This is Weft's take on [htmx's click-to-edit example](https://htmx.org/examples/click-to-edit/), and the shape is the same: the UI moves between two states, and each state is a server-rendered fragment. In Weft, each state is simply a component.
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
CONTACTS = {
|
|
11
|
+
"1" => { first_name: "Joe", last_name: "Blow", email: "joe@blow.com" }
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
class ContactCard < Weft::Component
|
|
15
|
+
builder_method :contact_card
|
|
16
|
+
|
|
17
|
+
attribute :contact_id
|
|
18
|
+
|
|
19
|
+
def build(attributes = {})
|
|
20
|
+
super
|
|
21
|
+
contact = CONTACTS.fetch(attrs.contact_id)
|
|
22
|
+
div { strong "First Name: "; text_node contact[:first_name] }
|
|
23
|
+
div { strong "Last Name: "; text_node contact[:last_name] }
|
|
24
|
+
div { strong "Email: "; text_node contact[:email] }
|
|
25
|
+
button "Click To Edit",
|
|
26
|
+
loads: ContactEditor, with: { contact_id: attrs.contact_id },
|
|
27
|
+
swap: :replace, target: self
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class ContactEditor < Weft::Component
|
|
32
|
+
builder_method :contact_editor
|
|
33
|
+
|
|
34
|
+
attribute :contact_id
|
|
35
|
+
attribute :first_name
|
|
36
|
+
attribute :last_name
|
|
37
|
+
attribute :email
|
|
38
|
+
|
|
39
|
+
transfers :save, to: ContactCard do |attrs|
|
|
40
|
+
CONTACTS.fetch(attrs.contact_id).merge!(
|
|
41
|
+
first_name: attrs.first_name, last_name: attrs.last_name, email: attrs.email
|
|
42
|
+
)
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build(attributes = {})
|
|
47
|
+
super
|
|
48
|
+
contact = CONTACTS.fetch(attrs.contact_id)
|
|
49
|
+
form(action: :save) do
|
|
50
|
+
input(type: "hidden", name: "contact_id", value: attrs.contact_id)
|
|
51
|
+
div do
|
|
52
|
+
label("First Name ", for: "first_name")
|
|
53
|
+
input(type: "text", name: "first_name", id: "first_name", value: contact[:first_name])
|
|
54
|
+
end
|
|
55
|
+
div do
|
|
56
|
+
label("Last Name ", for: "last_name")
|
|
57
|
+
input(type: "text", name: "last_name", id: "last_name", value: contact[:last_name])
|
|
58
|
+
end
|
|
59
|
+
div do
|
|
60
|
+
label("Email ", for: "email")
|
|
61
|
+
input(type: "text", name: "email", id: "email", value: contact[:email])
|
|
62
|
+
end
|
|
63
|
+
input(type: "submit", value: "Submit")
|
|
64
|
+
button "Cancel", type: "button",
|
|
65
|
+
loads: ContactCard, with: { contact_id: attrs.contact_id },
|
|
66
|
+
swap: :replace, target: self
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
(The `CONTACTS` hash stands in for your data layer — swap in ActiveRecord or whatever your app uses.)
|
|
73
|
+
|
|
74
|
+
## How it works
|
|
75
|
+
|
|
76
|
+
**Reads and writes get different verbs.** Opening the editor changes nothing on the server, so the Edit button is a [`loads:`](../dsl.md#loads) — a plain GET that fetches the editor component and swaps it over the card (`swap: :replace`). Cancel is the same thing pointed back at the card. Saving *does* change something, so it's a [`transfers`](../dsl.md#transfers--actions-that-render-something-else): a POST that runs the write, then renders the card — the natural "what you see after saving" — in the editor's place.
|
|
77
|
+
|
|
78
|
+
**`target: self` pins the swap to the component.** Inside `build`, `self` is the component instance, and a component reference as a `target:` resolves to its DOM id. Each fragment replaces the whole card/editor element, wherever it sits in the page.
|
|
79
|
+
|
|
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
|
+
|
|
82
|
+
**Form fields pair with declared attributes.** The editor declares `first_name`, `last_name`, and `email` so its fields reach the save callable as `attrs.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
|
+
|
|
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
|
+
|
|
86
|
+
## On the wire
|
|
87
|
+
|
|
88
|
+
The initial render (or `GET /_components/contact_card?contact_id=1`):
|
|
89
|
+
|
|
90
|
+
```html
|
|
91
|
+
<div id="contact-card-1">
|
|
92
|
+
<div><strong>First Name: </strong>Joe</div>
|
|
93
|
+
<div><strong>Last Name: </strong>Blow</div>
|
|
94
|
+
<div><strong>Email: </strong>joe@blow.com</div>
|
|
95
|
+
<button hx-get="/_components/contact_editor?contact_id=1"
|
|
96
|
+
hx-swap="outerHTML" hx-target="#contact-card-1">Click To Edit</button>
|
|
97
|
+
</div>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Clicking Edit fetches the editor; its form is wired to the save action, with the non-JS fallback visible:
|
|
101
|
+
|
|
102
|
+
```html
|
|
103
|
+
<form hx-post="/_components/contact_editor/save" hx-target="#contact-editor-1"
|
|
104
|
+
hx-swap="outerHTML" action="/_components/contact_editor/save" method="post">
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Submitting `POST /_components/contact_editor/save` with the edited fields returns the updated card — `<div id="contact-card-1">…Joseph…</div>` — which replaces the editor. The next fetch of the card confirms the write stuck.
|
|
108
|
+
|
|
109
|
+
## Related
|
|
110
|
+
|
|
111
|
+
- [Edit Row](edit-row.md) — this same pattern applied per-row in a table.
|
|
112
|
+
- [`loads:`](../dsl.md#loads) and [`transfers`](../dsl.md#transfers--actions-that-render-something-else) in the DSL reference.
|
|
113
|
+
- htmx's original uses a RESTful `PUT`; if you prefer that, `transfers :save, to: ContactCard, method: :put` does exactly what you'd hope.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Click to Load
|
|
2
|
+
|
|
3
|
+
A list that shows its first page up front, with a "load more" button at the bottom. Clicking the button replaces it with the next page of results — which brings its own button, until the data runs out. The page never reloads, and nothing already on screen is disturbed.
|
|
4
|
+
|
|
5
|
+
This is Weft's take on [htmx's click-to-load example](https://htmx.org/examples/click-to-load/), with the same cast of agents. One structural difference is worth knowing up front: htmx's version returns a loose fragment of sibling `<tr>`s to splice into a table, but a Weft component always renders exactly one wrapper element. So the Weft shape is a *chunk* — one element holding a page of results plus the next button — and each chunk lands where the previous button was. (For tables specifically, see [Infinite Scroll](infinite-scroll.md), whose append-style swap plays naturally with `<tbody>` chunks.)
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
AGENTS = (1..24).map { |n| { name: "Agent Smith", email: "void#{n}@null.org", number: n } }.freeze
|
|
11
|
+
|
|
12
|
+
class AgentRows < Weft::Component
|
|
13
|
+
builder_method :agent_rows
|
|
14
|
+
|
|
15
|
+
PER_PAGE = 6
|
|
16
|
+
|
|
17
|
+
attribute :page, default: 1
|
|
18
|
+
|
|
19
|
+
def build(attributes = {})
|
|
20
|
+
super
|
|
21
|
+
batch = AGENTS[(attrs.page - 1) * PER_PAGE, PER_PAGE]
|
|
22
|
+
batch.each do |agent|
|
|
23
|
+
div { strong agent[:name]; text_node " — #{agent[:email]} (##{agent[:number]})" }
|
|
24
|
+
end
|
|
25
|
+
if attrs.page * PER_PAGE < AGENTS.size
|
|
26
|
+
button "Load More Agents...", load_more: AgentRows, with: { page: attrs.page + 1 }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
(The `AGENTS` array stands in for your data layer — swap in ActiveRecord or whatever your app uses.)
|
|
33
|
+
|
|
34
|
+
Render `agent_rows(page: 1)` wherever the list lives, and the pattern takes care of itself from there.
|
|
35
|
+
|
|
36
|
+
## How it works
|
|
37
|
+
|
|
38
|
+
**One shorthand, no ceremony.** [`load_more:`](../dsl.md#shorthands) 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 attrs (`with: { page: attrs.page + 1 }`).
|
|
39
|
+
|
|
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
|
+
|
|
42
|
+
**Attributes make the chunk addressable.** Declaring `attribute :page, default: 1` gives the component a route ([Routing](../routing.md)) and coerces the wire value: `page=2` arrives as the string `"2"` and reaches `attrs.page` as the Integer `2`, because the default is an Integer.
|
|
43
|
+
|
|
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
|
+
|
|
46
|
+
## On the wire
|
|
47
|
+
|
|
48
|
+
The initial render (or `GET /_components/agent_rows?page=1`):
|
|
49
|
+
|
|
50
|
+
```html
|
|
51
|
+
<div id="agent-rows-1">
|
|
52
|
+
<div><strong>Agent Smith</strong> — void1@null.org (#1)</div>
|
|
53
|
+
<!-- … five more agents … -->
|
|
54
|
+
<button hx-get="/_components/agent_rows?page=2" hx-swap="outerHTML"
|
|
55
|
+
hx-target="this" hx-trigger="click">Load More Agents...</button>
|
|
56
|
+
</div>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Every htmx attribute there came from the one `load_more:` kwarg. Clicking issues `GET /_components/agent_rows?page=2`, and the response replaces the button:
|
|
60
|
+
|
|
61
|
+
```html
|
|
62
|
+
<div id="agent-rows-2">
|
|
63
|
+
<div><strong>Agent Smith</strong> — void7@null.org (#7)</div>
|
|
64
|
+
<!-- … -->
|
|
65
|
+
<button hx-get="/_components/agent_rows?page=3" hx-swap="outerHTML"
|
|
66
|
+
hx-target="this" hx-trigger="click">Load More Agents...</button>
|
|
67
|
+
</div>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The last page (`GET /_components/agent_rows?page=4`) renders its agents and no button — the interaction retires itself.
|
|
71
|
+
|
|
72
|
+
## Related
|
|
73
|
+
|
|
74
|
+
- [Infinite Scroll](infinite-scroll.md) — the same next-page mechanic, triggered by scrolling instead of a click, in a real table.
|
|
75
|
+
- [Lazy Loading](lazy-loading.md) — deferring one expensive section rather than paginating many.
|
|
76
|
+
- The [shorthands table](../dsl.md#shorthands) in the DSL reference, including how to register your own preset.
|
|
77
|
+
- For pagination that *replaces* the current page instead of accumulating, [`navigate:`](../dsl.md#navigate) is the better verb.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Delete Row
|
|
2
|
+
|
|
3
|
+
A table of contacts, each row with a Delete button. Clicking it asks the browser's native "Are you sure?", deletes the record on the server, and removes the row from the table.
|
|
4
|
+
|
|
5
|
+
This is Weft's take on [htmx's delete-row example](https://htmx.org/examples/delete-row/). Their version hoists the htmx attributes onto the `<tbody>` and lets every row inherit them; a Weft row is a component that carries its own wiring, generated from its own declaration. One cosmetic difference: htmx's original fades the row out over a second before removing it — that's a CSS transition plus a swap delay, and their approach carries over unchanged if you want it. This version keeps zero CSS and removes the row immediately.
|
|
6
|
+
|
|
7
|
+
## The components
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
CONTACT_BOOK = {
|
|
11
|
+
"1" => { name: "Angie MacDowell", email: "angie@macdowell.org", status: "Active" },
|
|
12
|
+
"2" => { name: "Fuqua Tarkenton", email: "fuqua@tarkenton.org", status: "Active" },
|
|
13
|
+
"3" => { name: "Kim Yee", email: "kim@yee.org", status: "Inactive" }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class ContactRow < Weft::Component
|
|
17
|
+
builder_method :contact_row
|
|
18
|
+
|
|
19
|
+
attribute :contact_id
|
|
20
|
+
|
|
21
|
+
dismisses :destroy do |attrs|
|
|
22
|
+
CONTACT_BOOK.delete(attrs.contact_id)
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def tag_name
|
|
27
|
+
"tr"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def build(attributes = {})
|
|
31
|
+
super
|
|
32
|
+
contact = CONTACT_BOOK[attrs.contact_id]
|
|
33
|
+
return unless contact
|
|
34
|
+
|
|
35
|
+
td contact[:name]
|
|
36
|
+
td contact[:email]
|
|
37
|
+
td contact[:status]
|
|
38
|
+
td do
|
|
39
|
+
button "Delete", action: :destroy, "hx-confirm" => "Are you sure?"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class ContactsTable < Weft::Component
|
|
45
|
+
builder_method :contacts_table
|
|
46
|
+
|
|
47
|
+
def build(attributes = {})
|
|
48
|
+
super
|
|
49
|
+
table do
|
|
50
|
+
thead { tr { th "Name"; th "Email"; th "Status"; th "" } }
|
|
51
|
+
tbody do
|
|
52
|
+
CONTACT_BOOK.each_key { |id| contact_row(contact_id: id) }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
(The `CONTACT_BOOK` hash stands in for your data layer — swap in ActiveRecord or whatever your app uses.)
|
|
60
|
+
|
|
61
|
+
## How it works
|
|
62
|
+
|
|
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 attribute 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.
|
|
64
|
+
|
|
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 attrs 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
|
+
**The confirmation is one raw attribute.** Kwargs Weft doesn't recognize pass straight through to the element, so `"hx-confirm" => "Are you sure?"` lands on the button as-is and htmx shows the browser's native confirm dialog before sending anything. No request fires on Cancel. For the fuller confirm-and-prompt story, see [Browser Dialogs](browser-dialogs.md).
|
|
68
|
+
|
|
69
|
+
**The dismissal response is rendered, then thrown away.** htmx ignores the response body on a delete swap, but Weft still renders the component once after the callable runs — so `build` must survive the record being gone, hence the `return unless contact` guard (an empty `<tr>` nobody will see). The trailing `nil` in the callable matters for the same reason: `Hash#delete` returns the deleted record, and a hash returned from a callable merges into the attrs for that final render. 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.
|
|
70
|
+
|
|
71
|
+
## On the wire
|
|
72
|
+
|
|
73
|
+
Each row arrives fully wired — `GET /_components/contact_row?contact_id=1` returns the same fragment the table renders inline:
|
|
74
|
+
|
|
75
|
+
```html
|
|
76
|
+
<tr id="contact-row-1">
|
|
77
|
+
<td>Angie MacDowell</td>
|
|
78
|
+
<td>angie@macdowell.org</td>
|
|
79
|
+
<td>Active</td>
|
|
80
|
+
<td>
|
|
81
|
+
<button hx-confirm="Are you sure?" hx-delete="/_components/contact_row/destroy"
|
|
82
|
+
hx-target="#contact-row-1" hx-swap="delete"
|
|
83
|
+
hx-vals="{"contact_id":"1"}">Delete</button>
|
|
84
|
+
</td>
|
|
85
|
+
</tr>
|
|
86
|
+
```
|
|
87
|
+
|
|
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 the guarded, now-empty render — which htmx discards while removing the row:
|
|
89
|
+
|
|
90
|
+
```html
|
|
91
|
+
<tr id="contact-row-1"></tr>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Fetching the table again shows two rows; fetching the deleted row's own URL returns the same empty `<tr>`, confirming the record is gone.
|
|
95
|
+
|
|
96
|
+
## Related
|
|
97
|
+
|
|
98
|
+
- [Browser Dialogs](browser-dialogs.md) — the confirm/prompt story in full.
|
|
99
|
+
- [Edit Row](edit-row.md) — rows that switch into an editable state instead of disappearing.
|
|
100
|
+
- [Inline Expansion](inline-expansion.md) — another `tag_name "tr"` component, inserted rather than removed.
|
|
101
|
+
- [`dismisses`](../dsl.md#dismisses--remove-from-the-dom) in the DSL reference.
|