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/tutorial.md
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
# Build your first Weft app
|
|
2
|
+
|
|
3
|
+
In this tutorial we'll build a small RSVP tracker: a list of events, a detail page per event, a form for RSVPing, and an attendee list that updates live. By the end you'll have written pages, components, a user action with validation, and a polling live update — with no routes file, no controllers, and no JavaScript.
|
|
4
|
+
|
|
5
|
+
Expect it to take twenty to thirty minutes. You'll need Ruby 3.2 or newer and basic familiarity with Bundler. Every step ends with something you can see working before moving on.
|
|
6
|
+
|
|
7
|
+
**The steps:**
|
|
8
|
+
|
|
9
|
+
- [1. Scaffold the project](#1-scaffold-the-project)
|
|
10
|
+
- [2. Wire up the app](#2-wire-up-the-app)
|
|
11
|
+
- [3. Your first page](#3-your-first-page)
|
|
12
|
+
- [4. Some data](#4-some-data)
|
|
13
|
+
- [5. The event page](#5-the-event-page)
|
|
14
|
+
- [6. Your first component](#6-your-first-component)
|
|
15
|
+
- [7. Taking RSVPs](#7-taking-rsvps)
|
|
16
|
+
- [8. Going live](#8-going-live)
|
|
17
|
+
- [Where to go from here](#where-to-go-from-here)
|
|
18
|
+
|
|
19
|
+
## 1. Scaffold the project
|
|
20
|
+
|
|
21
|
+
Create a directory with this shape:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
rsvp/
|
|
25
|
+
├── Gemfile
|
|
26
|
+
├── config.ru
|
|
27
|
+
├── config/
|
|
28
|
+
│ └── environment.rb
|
|
29
|
+
└── app/
|
|
30
|
+
├── components/
|
|
31
|
+
├── data/
|
|
32
|
+
└── pages/
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The layout is a convention, not a requirement, but it's the one Weft apps generally follow: `app/pages/` for full-page views, `app/components/` for the interactive pieces that compose into them, and (in our case) `app/data/` for a toy data layer.
|
|
36
|
+
|
|
37
|
+
The `Gemfile`:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
source "https://rubygems.org"
|
|
41
|
+
|
|
42
|
+
gem "weft"
|
|
43
|
+
gem "puma" # a web server
|
|
44
|
+
gem "rackup" # the `rackup` command (split out of Rack itself in Rack 3)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Those second two lines matter. Weft runs on Rack, and since Rack 3 the `rackup` command ships as its own gem — without it, `bundle exec rackup` fails with a cryptic `can't find executable rackup for gem rack`. Adding `puma` and `rackup` up front saves you that detour.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
bundle install
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 2. Wire up the app
|
|
54
|
+
|
|
55
|
+
`config.ru` is the whole server story — Weft's Router *is* the Rack app:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
require_relative "config/environment"
|
|
59
|
+
|
|
60
|
+
run Weft::Router
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`config/environment.rb` is where your application loads. Weft doesn't dictate this file, but here's a shape that works:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
require "bundler/setup"
|
|
67
|
+
require "weft"
|
|
68
|
+
|
|
69
|
+
APP_ROOT = File.expand_path("..", __dir__)
|
|
70
|
+
|
|
71
|
+
# Load the application: data first, then components, then pages
|
|
72
|
+
# (pages compose components). Within each directory, files load
|
|
73
|
+
# alphabetically.
|
|
74
|
+
%w[data components pages].each do |dir|
|
|
75
|
+
Dir[File.join(APP_ROOT, "app", dir, "*.rb")].sort.each { |file| require file }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
Weft.configure do |c|
|
|
79
|
+
c.auto_reload = true
|
|
80
|
+
c.reload_paths = [File.join(APP_ROOT, "app", "**", "*.rb")]
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Two things to notice:
|
|
85
|
+
|
|
86
|
+
- **Loading is just `require`.** Weft discovers your pages and components the moment their classes are defined — there's nothing to register. The directory ordering matters a little: if a component references another class *in its class body* (you'll see `includes AttendeeList` later), the referenced file has to load first. Our data → components → pages ordering plus alphabetical luck covers this tutorial; a growing app eventually wants a real autoloader like Zeitwerk.
|
|
87
|
+
- **Turn on `auto_reload` before your first run.** In a moment you'll be editing files and refreshing the browser; with these two settings, your edits apply without restarting the server. (In a real app you'd gate this on an environment check — see [Configuration](configuration.md#auto_reload).)
|
|
88
|
+
|
|
89
|
+
## 3. Your first page
|
|
90
|
+
|
|
91
|
+
Create `app/pages/events_page.rb`:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
class EventsPage < Weft::Page
|
|
95
|
+
def build(attributes = {})
|
|
96
|
+
attributes[:title] = "Upcoming Events"
|
|
97
|
+
super
|
|
98
|
+
h1 "Upcoming Events"
|
|
99
|
+
para "If you can read this in the browser, the app is wired up."
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
A page is a class. `build` describes its content using [Arbre](arbre.md)'s HTML builder methods — `h1`, `ul`, `div`, and friends — as plain Ruby. The `super` call renders the document shell around you: doctype, `<head>` with the htmx script, `<body>`. Setting `attributes[:title]` before `super` puts your title in the `<head>`.
|
|
105
|
+
|
|
106
|
+
Start the server and have a look:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
bundle exec rackup
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Visit [http://localhost:9292/events](http://localhost:9292/events). You should see the heading and the paragraph.
|
|
113
|
+
|
|
114
|
+
Nobody told Weft about that URL. The route came from the class name: `EventsPage`, minus the `Page` suffix, snake-cased — `/events`. (The suffix is optional; a class named `Events` routes to the same place. See [Routing](routing.md) for the full derivation rules.)
|
|
115
|
+
|
|
116
|
+
> **The `p` gotcha — read this before it costs you an hour.** The one HTML tag you *can't* write the obvious way is the paragraph. Ruby's built-in `Kernel#p` (the debugging printer) shadows the `<p>` builder, so this:
|
|
117
|
+
>
|
|
118
|
+
> ```ruby
|
|
119
|
+
> p "If you can read this in the browser, the app is wired up."
|
|
120
|
+
> ```
|
|
121
|
+
>
|
|
122
|
+
> renders no paragraph at all — and the text goes to your *server terminal* instead, courtesy of `Kernel#p`. Nothing errors; the paragraph is just silently missing. Use **`para`** for paragraphs, always.
|
|
123
|
+
|
|
124
|
+
Two more things worth ten seconds each while the server is up:
|
|
125
|
+
|
|
126
|
+
- Visit [http://localhost:9292/](http://localhost:9292/) — a styled "Not found" page, for free. Weft ships default error and not-found handling out of the box ([Error handling](error-handling.md)).
|
|
127
|
+
- Edit the `para` text and refresh — the change appears without a restart. That's `auto_reload` earning its keep.
|
|
128
|
+
|
|
129
|
+
## 4. Some data
|
|
130
|
+
|
|
131
|
+
Weft has no opinions about your data layer — use ActiveRecord, Sequel, an API client, whatever your app needs. For this tutorial, a hash in memory is plenty. Create `app/data/event_store.rb`:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
# An in-memory store with a couple of seed events. RSVPs live in a
|
|
135
|
+
# name => answer hash per event. State resets when the server restarts —
|
|
136
|
+
# fine for learning.
|
|
137
|
+
module EventStore
|
|
138
|
+
Event = Struct.new(:id, :name, :date, :location, :rsvps)
|
|
139
|
+
|
|
140
|
+
EVENTS = {
|
|
141
|
+
"summer-bbq" => Event.new(
|
|
142
|
+
"summer-bbq", "Summer BBQ", "Saturday July 18, 4pm", "Riverside Park",
|
|
143
|
+
{ "Priya" => "yes" }
|
|
144
|
+
),
|
|
145
|
+
"trivia-night" => Event.new(
|
|
146
|
+
"trivia-night", "Trivia Night", "Thursday July 23, 7pm", "The Rusty Anchor",
|
|
147
|
+
{}
|
|
148
|
+
)
|
|
149
|
+
}.freeze
|
|
150
|
+
|
|
151
|
+
def self.all = EVENTS.values
|
|
152
|
+
def self.find(id) = EVENTS[id]
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
And make `EventsPage` list the real events:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
class EventsPage < Weft::Page
|
|
160
|
+
def build(attributes = {})
|
|
161
|
+
attributes[:title] = "Upcoming Events"
|
|
162
|
+
super
|
|
163
|
+
h1 "Upcoming Events"
|
|
164
|
+
ul do
|
|
165
|
+
EventStore.all.each do |event|
|
|
166
|
+
li do
|
|
167
|
+
a event.name, href: "/events/#{event.id}"
|
|
168
|
+
text_node " — #{event.date}"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
(`text_node` inserts plain text next to other elements — handy when a line mixes a link and loose text.)
|
|
177
|
+
|
|
178
|
+
**Restart the server for this one.** `auto_reload` re-runs files it already knows about, but `event_store.rb` is a *new* file — the loader glob ran at boot, before it existed. If you refresh without restarting, you'll get Weft's error page with `uninitialized constant EventsPage::EventStore`, which is your cue. New file → restart; edits to existing files → just refresh.
|
|
179
|
+
|
|
180
|
+
After the restart, `/events` lists both events as links. They 404 — let's fix that.
|
|
181
|
+
|
|
182
|
+
## 5. The event page
|
|
183
|
+
|
|
184
|
+
Create `app/pages/event_page.rb`:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
class EventPage < Weft::Page
|
|
188
|
+
self.page_path = "/events/:event_id"
|
|
189
|
+
|
|
190
|
+
attribute :event_id
|
|
191
|
+
|
|
192
|
+
def build(attributes = {})
|
|
193
|
+
event = EventStore.find(attributes[:event_id])
|
|
194
|
+
raise Weft::NotFound, "no event called #{attributes[:event_id]}" unless event
|
|
195
|
+
|
|
196
|
+
attributes[:title] = event.name
|
|
197
|
+
super
|
|
198
|
+
h1 event.name
|
|
199
|
+
para "#{event.date} — #{event.location}"
|
|
200
|
+
a "← All events", href: "/events"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Restart (new file), then click through to an event. Two new ideas here:
|
|
206
|
+
|
|
207
|
+
**Attributes are a page's wire state.** `attribute :event_id` declares that this page is parameterized, and the `page_path` pattern says where the value comes from: `/events/summer-bbq` gives the page `event_id = "summer-bbq"`. A page with attributes needs an explicit `page_path` — there's no way to derive a parameterized pattern from a class name, and Weft will tell you exactly that if you forget.
|
|
208
|
+
|
|
209
|
+
One timing wrinkle: before the `super` call, read incoming values from the raw `attributes` hash (as above). After `super`, the resolved values are available the nicer way — `attrs.event_id`. You'll see `attrs` used in the components below, where `super` comes first.
|
|
210
|
+
|
|
211
|
+
**Raising is error handling.** For an unknown event, we `raise Weft::NotFound` and we're done — Weft turns it into a proper 404 response with its default not-found page. Try [http://localhost:9292/events/nope](http://localhost:9292/events/nope). There's a whole family of semantic errors (`Weft::Unprocessable` will appear shortly), and everything about the resulting rendering is customizable — see [Error handling](error-handling.md).
|
|
212
|
+
|
|
213
|
+
## 6. Your first component
|
|
214
|
+
|
|
215
|
+
Pages are destinations; **components** are the composable, interactive pieces inside them. Create `app/components/attendee_list.rb`:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
class AttendeeList < Weft::Component
|
|
219
|
+
builder_method :attendee_list
|
|
220
|
+
|
|
221
|
+
attribute :event_id
|
|
222
|
+
|
|
223
|
+
def build(attributes = {})
|
|
224
|
+
super
|
|
225
|
+
event = EventStore.find(attrs.event_id)
|
|
226
|
+
h2 "Who's coming"
|
|
227
|
+
if event.rsvps.empty?
|
|
228
|
+
para "No RSVPs yet. Be the first!"
|
|
229
|
+
else
|
|
230
|
+
ul do
|
|
231
|
+
event.rsvps.each do |name, answer|
|
|
232
|
+
li "#{name} — #{answer}"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`builder_method :attendee_list` is the composition idiom: it makes `attendee_list(...)` available as a builder inside any other `build`, just like `h1` and `ul`. Declare one on every component — it's how components nest naturally.
|
|
241
|
+
|
|
242
|
+
Add it to `EventPage`, before the back-link:
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
attendee_list(event_id: event.id)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Restart, and the Summer BBQ page shows Priya under "Who's coming".
|
|
249
|
+
|
|
250
|
+
View the page source and look at the wrapper Weft rendered:
|
|
251
|
+
|
|
252
|
+
```html
|
|
253
|
+
<div id="attendee-list-summer-bbq">
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
That DOM id was derived, not written: the class name, plus the value of the component's **first declared attribute**. The convention matters — it's how updates land on the right element when several instances share a page — so declare the identifying attribute first. (A list of attendee *rows*, say, would want `attribute :name` first, or every row would collide on the same event-derived id.)
|
|
257
|
+
|
|
258
|
+
One more thing, and it's the heart of Weft. Your component isn't just markup inside the page — it's independently addressable:
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
curl "http://localhost:9292/_components/attendee_list?event_id=summer-bbq"
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
That returns the component alone, as an HTML fragment, rendered fresh. Weft routed it automatically, the same way it routed your pages. Everything in the rest of this tutorial — form submissions, live updates — is machinery that fetches components like this and swaps them into the page. See [Routing](routing.md) for the details.
|
|
265
|
+
|
|
266
|
+
## 7. Taking RSVPs
|
|
267
|
+
|
|
268
|
+
Now the interactive part. Create `app/components/rsvp_form.rb`:
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
class RSVPForm < Weft::Component
|
|
272
|
+
builder_method :rsvp_form
|
|
273
|
+
|
|
274
|
+
attribute :event_id
|
|
275
|
+
attribute :name
|
|
276
|
+
attribute :answer
|
|
277
|
+
attribute :error_message
|
|
278
|
+
|
|
279
|
+
includes AttendeeList
|
|
280
|
+
|
|
281
|
+
performs :submit do |attrs|
|
|
282
|
+
event = EventStore.find(attrs.event_id)
|
|
283
|
+
name = attrs.name.to_s.strip
|
|
284
|
+
raise Weft::Unprocessable, "Please tell us your name." if name.empty?
|
|
285
|
+
|
|
286
|
+
event.rsvps[name] = attrs.answer
|
|
287
|
+
nil
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
recovers from: Weft::Unprocessable do |_attrs, error|
|
|
291
|
+
{ error_message: error.message }
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def build(attributes = {})
|
|
295
|
+
super
|
|
296
|
+
h2 "RSVP"
|
|
297
|
+
para(attrs.error_message, style: "color:#b91c1c") if attrs.error_message
|
|
298
|
+
form(action: :submit) do
|
|
299
|
+
input(type: "hidden", name: "event_id", value: attrs.event_id)
|
|
300
|
+
label("Your name: ", for: "name")
|
|
301
|
+
input(type: "text", name: "name", id: "name")
|
|
302
|
+
label(" Coming? ", for: "answer")
|
|
303
|
+
select(name: "answer", id: "answer") do
|
|
304
|
+
%w[yes no maybe].each { |ans| option(ans, value: ans) }
|
|
305
|
+
end
|
|
306
|
+
input(type: "submit", value: "Send RSVP")
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Add it to `EventPage` above the attendee list:
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
rsvp_form(event_id: event.id)
|
|
316
|
+
attendee_list(event_id: event.id)
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Restart, open Trivia Night, RSVP as yourself — **the attendee list updates without a page reload**, and the form clears. Then try submitting with a blank name: a red message appears in the form, and the list is untouched.
|
|
320
|
+
|
|
321
|
+
That's a lot from one class. Unpacking it:
|
|
322
|
+
|
|
323
|
+
**`performs :submit` declares a user action.** The block is the behavior: it receives the component's resolved attributes, does its work, and whatever it returns directs what renders next — `nil` means "re-render me fresh" (our success path). Weft generates the route for the action; you never wrote one.
|
|
324
|
+
|
|
325
|
+
**`form(action: :submit)` wires the form to the action.** Look at the rendered HTML:
|
|
326
|
+
|
|
327
|
+
```html
|
|
328
|
+
<form hx-post="/_components/rsvp_form/submit" hx-target="#rsvp-form-trivia-night"
|
|
329
|
+
hx-swap="outerHTML" action="/_components/rsvp_form/submit" method="post">
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
The `hx-*` attributes make the form submit in place. The plain `action` and `method` attributes are there too, so the form still works with JavaScript disabled — it degrades to a normal POST.
|
|
333
|
+
|
|
334
|
+
**Form fields map to declared attributes, one to one.** The action block reads `attrs.name` and `attrs.answer` because the form has fields named `name` and `answer` *and* the component declares attributes of the same names. Both halves are needed: declared-but-not-a-field values don't travel (that's why `event_id` rides along as a hidden input — it's part of the component's identity, not something the user types), and field-but-not-declared values are ignored.
|
|
335
|
+
|
|
336
|
+
**Validation is a raise plus a recovery.** The action raises `Weft::Unprocessable`; the `recovers` declaration catches it, and its block returns extra attributes to merge into the re-render — here, `error_message`, which `build` displays when present. Note that `error_message` is itself a declared attribute: recovery data flows through the same schema as everything else. The response even carries a semantically-correct 422 status. See [Error handling](error-handling.md) for how far this system goes.
|
|
337
|
+
|
|
338
|
+
**`includes AttendeeList` updates the list in the same response.** Submitting the form changes data that *another* component displays. This declaration says: whenever RSVPForm responds to an action, render AttendeeList too, marked so it swaps into its own place in the page (by that derived DOM id — this is why the convention exists). One interaction, two regions updated, zero JavaScript.
|
|
339
|
+
|
|
340
|
+
## 8. Going live
|
|
341
|
+
|
|
342
|
+
The attendee list updates when *you* RSVP — but not when someone else does. One line fixes that. In `AttendeeList`, under the attribute:
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
refreshes every: 10
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
This edit hot-reloads — no restart. Open the same event in two browser windows, RSVP in one, and within ten seconds the other window's list catches up. The component now polls its own URL (the one you curled in step 6) and swaps itself:
|
|
349
|
+
|
|
350
|
+
```html
|
|
351
|
+
<div id="attendee-list-summer-bbq"
|
|
352
|
+
hx-get="/_components/attendee_list?event_id=summer-bbq"
|
|
353
|
+
hx-trigger="every 10s" hx-swap="outerHTML">
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Declared once on the class, the behavior is present in the initial page render *and* in every refreshed fragment, so it keeps polling forever. Polling is the simplest live-update verb; `pushes` gives you server-sent events with the same one-line flavor ([The Weft DSL](dsl.md#pushes--the-server-sends-updates)).
|
|
357
|
+
|
|
358
|
+
## Where to go from here
|
|
359
|
+
|
|
360
|
+
You've built pages that route themselves, components that compose and self-address, a validated user action with out-of-band updates, and a live-polling list — the core of how Weft apps are put together.
|
|
361
|
+
|
|
362
|
+
**An exercise, if you're enjoying yourself:** add a "withdraw" button next to each attendee. You'll want a per-attendee component (careful which attribute you declare first — each row needs its own DOM id), and the `dismisses` verb, which removes a component from the page when its action succeeds. The [DSL reference](dsl.md#dismisses--remove-from-the-dom) has what you need.
|
|
363
|
+
|
|
364
|
+
**A finishing touch:** the events list living at `/events` leaves `/` as a 404. Give `EventsPage` an explicit home: `self.page_path = "/"`.
|
|
365
|
+
|
|
366
|
+
**The reference docs**, when you want the full picture:
|
|
367
|
+
|
|
368
|
+
- [The Weft DSL](dsl.md) — every verb (`transfers`, `pushes`, `dismisses`, `triggers`…), the element kwargs, and the interaction shorthands (tooltips, modals, lazy loading) this tutorial didn't touch.
|
|
369
|
+
- [Arbre: the HTML layer](arbre.md) — the HTML builder underneath every `build` method: its argument conventions, text handling, container patterns, and gotchas beyond `para`.
|
|
370
|
+
- [Routing](routing.md) — how paths derive, what's routable, collision detection.
|
|
371
|
+
- [Error handling](error-handling.md) — the error family, recovery chains, branding your error pages.
|
|
372
|
+
- [Configuration](configuration.md) — every setting, including production concerns like static assets and quieter error pages.
|
data/lib/weft/action.rb
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Weft
|
|
6
|
+
# Metadata for a single component action (declared via `performs` or `transfers`).
|
|
7
|
+
# Knows how to derive its route path and generate htmx attributes.
|
|
8
|
+
#
|
|
9
|
+
# Every Action knows what it `renders` — the component class rendered in the
|
|
10
|
+
# response. For `performs`, that's the declaring class (re-render self).
|
|
11
|
+
# For `transfers`, it's the `to:` class (render something else).
|
|
12
|
+
# The Router always renders `action.renders` — no branching.
|
|
13
|
+
#
|
|
14
|
+
# `target` is the other kind of target — the CSS selector for where the
|
|
15
|
+
# response lands in the DOM (hx-target). Nil means the component's own element.
|
|
16
|
+
class Action
|
|
17
|
+
attr_reader :name, :method, :swap, :target, :renders, :callable
|
|
18
|
+
|
|
19
|
+
def initialize(name:, renders:, method: :post, swap: :outer_html, target: nil, callable: nil) # rubocop:disable Metrics/ParameterLists
|
|
20
|
+
@name = name
|
|
21
|
+
@method = method
|
|
22
|
+
@swap = swap
|
|
23
|
+
@target = target
|
|
24
|
+
@renders = renders
|
|
25
|
+
@callable = callable
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
SWAP_VALUES = {
|
|
29
|
+
# Semantic names (preferred)
|
|
30
|
+
replace: "outerHTML",
|
|
31
|
+
fill: "innerHTML",
|
|
32
|
+
before: "beforebegin",
|
|
33
|
+
after: "afterend",
|
|
34
|
+
append: "beforeend",
|
|
35
|
+
prepend: "afterbegin",
|
|
36
|
+
remove: "delete",
|
|
37
|
+
# htmx-native names (also accepted)
|
|
38
|
+
outer_html: "outerHTML",
|
|
39
|
+
inner_html: "innerHTML",
|
|
40
|
+
before_begin: "beforebegin",
|
|
41
|
+
after_begin: "afterbegin",
|
|
42
|
+
before_end: "beforeend",
|
|
43
|
+
after_end: "afterend",
|
|
44
|
+
delete: "delete",
|
|
45
|
+
none: "none"
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
TRIGGER_VALUES = {
|
|
49
|
+
click: "click",
|
|
50
|
+
hover: "mouseenter once",
|
|
51
|
+
visible: "revealed",
|
|
52
|
+
input: "input changed delay:300ms"
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
def nameless? = @name.nil?
|
|
56
|
+
|
|
57
|
+
# The URL path for this action, given the component's base path.
|
|
58
|
+
def route_path(component_path)
|
|
59
|
+
nameless? ? component_path : "#{component_path}/#{name}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Generate htmx attributes for an element that triggers this action.
|
|
63
|
+
def to_htmx_attrs(component)
|
|
64
|
+
path = route_path(component.class.resolved_component_path)
|
|
65
|
+
{
|
|
66
|
+
"hx-#{method}" => path,
|
|
67
|
+
"hx-target" => target || "##{component.weft_id}",
|
|
68
|
+
"hx-swap" => self.class.resolve_swap(swap),
|
|
69
|
+
"hx-vals" => component.attrs.to_h.to_json
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Resolve a swap symbol or string to its htmx value.
|
|
74
|
+
def self.resolve_swap(value)
|
|
75
|
+
SWAP_VALUES.fetch(value, value.to_s)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Resolve a trigger symbol or string to its htmx value.
|
|
79
|
+
def self.resolve_trigger(value)
|
|
80
|
+
TRIGGER_VALUES.fetch(value, value.to_s)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Weft
|
|
4
|
+
# Value object representing a component's resolved wire attributes.
|
|
5
|
+
# Provides method-style access with a clear collision-resolution rule:
|
|
6
|
+
# declared attribute names win, then the underlying Hash API is available
|
|
7
|
+
# for any name not declared as an attribute.
|
|
8
|
+
#
|
|
9
|
+
# Action callables receive a ready-made instance (the sole argument to a
|
|
10
|
+
# +performs+/+transfers+ block); you don't construct these yourself:
|
|
11
|
+
#
|
|
12
|
+
# attrs.status # => "shipped" (declared attribute)
|
|
13
|
+
# attrs.count # => 42 (declared attribute — wins over Hash#count)
|
|
14
|
+
# attrs[:status] # => "shipped" (explicit hash access)
|
|
15
|
+
# attrs.select { ... } # delegates to the underlying hash
|
|
16
|
+
# attrs.to_h # => the underlying hash (explicit escape hatch)
|
|
17
|
+
class Attributes
|
|
18
|
+
# Build an Attributes instance by extracting declared keys from a raw
|
|
19
|
+
# attributes hash, applying defaults for missing keys. Does not mutate
|
|
20
|
+
# the raw hash.
|
|
21
|
+
#
|
|
22
|
+
# schema = { status: { default: "pending" }, count: { default: 0 } }
|
|
23
|
+
# raw = { status: "shipped", class: "big" }
|
|
24
|
+
# Weft::Attributes.extract_from(raw, using: schema)
|
|
25
|
+
# # => Attributes{ status: "shipped", count: 0 }
|
|
26
|
+
def self.extract_from(raw, using:)
|
|
27
|
+
data = using.to_h do |name, meta|
|
|
28
|
+
[name, raw.fetch(name, meta[:default])]
|
|
29
|
+
end
|
|
30
|
+
new(data)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @api private
|
|
34
|
+
# Constructed internally (see {.extract_from} and the Router's resolver).
|
|
35
|
+
def initialize(data)
|
|
36
|
+
@data = data
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def [](key)
|
|
40
|
+
@data[key]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def key?(key)
|
|
44
|
+
@data.key?(key)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
@data
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def respond_to_missing?(name, include_private = false)
|
|
52
|
+
@data.key?(name) || @data.respond_to?(name, include_private) || super
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
56
|
+
if @data.key?(name) && args.empty? && kwargs.empty? && !block
|
|
57
|
+
@data[name]
|
|
58
|
+
elsif @data.respond_to?(name)
|
|
59
|
+
@data.public_send(name, *args, **kwargs, &block)
|
|
60
|
+
else
|
|
61
|
+
super
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|