weft 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +134 -28
- data/README.md +46 -23
- data/docs/app-patterns.md +8 -7
- data/docs/arbre.md +49 -18
- data/docs/configuration.md +42 -35
- data/docs/dsl.md +356 -105
- data/docs/error-handling.md +64 -24
- data/docs/examples/active-search.md +10 -10
- data/docs/examples/browser-dialogs.md +10 -10
- data/docs/examples/bulk-update.md +11 -11
- data/docs/examples/click-to-edit.md +14 -14
- data/docs/examples/click-to-load.md +8 -8
- data/docs/examples/delete-row.md +17 -19
- data/docs/examples/edit-row.md +17 -14
- data/docs/examples/file-upload.md +5 -5
- data/docs/examples/infinite-scroll.md +8 -8
- data/docs/examples/inline-expansion.md +8 -8
- data/docs/examples/inline-validation.md +17 -17
- data/docs/examples/lazy-loading.md +8 -8
- data/docs/examples/live-ticker.md +1 -1
- data/docs/examples/modal-dialog.md +3 -3
- data/docs/examples/progress-bar.md +1 -1
- data/docs/examples/reset-user-input.md +7 -7
- data/docs/examples/tabs.md +4 -4
- data/docs/examples/tooltip.md +8 -8
- data/docs/examples/updating-other-content.md +9 -9
- data/docs/examples/value-select.md +11 -11
- data/docs/params.md +112 -0
- data/docs/routing.md +13 -13
- data/docs/tutorial.md +46 -48
- data/lib/weft/action.rb +4 -2
- data/lib/weft/autoloading.rb +69 -0
- data/lib/weft/component.rb +97 -31
- data/lib/weft/configuration.rb +37 -5
- data/lib/weft/context/expansion.rb +184 -0
- data/lib/weft/context/interception.rb +22 -2
- data/lib/weft/context/modifiers.rb +78 -0
- data/lib/weft/context/traversal.rb +80 -0
- data/lib/weft/context/wiring.rb +85 -0
- data/lib/weft/context.rb +70 -164
- data/lib/weft/defaults/error_component.rb +57 -21
- data/lib/weft/defaults/error_page.rb +12 -10
- data/lib/weft/defaults/not_found_component.rb +14 -12
- data/lib/weft/defaults/not_found_page.rb +9 -8
- data/lib/weft/dsl/actions.rb +9 -9
- data/lib/weft/dsl/inclusions.rb +48 -11
- data/lib/weft/dsl/params.rb +265 -0
- data/lib/weft/dsl/recoveries.rb +36 -6
- data/lib/weft/dsl/sandbox.rb +26 -0
- data/lib/weft/dsl/triggers.rb +28 -8
- data/lib/weft/dsl/updates.rb +31 -8
- data/lib/weft/error.rb +12 -1
- data/lib/weft/page/assets.rb +222 -0
- data/lib/weft/page/head.rb +87 -0
- data/lib/weft/page.rb +55 -239
- data/lib/weft/params/assembly.rb +170 -0
- data/lib/weft/params.rb +138 -0
- data/lib/weft/presets.rb +96 -0
- data/lib/weft/redirect.rb +7 -7
- data/lib/weft/registry/eligibility.rb +5 -19
- data/lib/weft/registry.rb +58 -18
- data/lib/weft/resolver.rb +48 -20
- data/lib/weft/router/actions.rb +106 -22
- data/lib/weft/router/errors.rb +223 -83
- data/lib/weft/router/oob_includes.rb +202 -17
- data/lib/weft/router/streaming.rb +86 -20
- data/lib/weft/router.rb +33 -25
- data/lib/weft/version.rb +1 -1
- data/lib/weft.rb +37 -24
- metadata +32 -8
- data/lib/weft/attributes.rb +0 -65
- data/lib/weft/dsl/attributes.rb +0 -43
- data/lib/weft/shorthands.rb +0 -57
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "arbre"
|
|
4
|
+
|
|
5
|
+
module Weft
|
|
6
|
+
class Context < Arbre::Context
|
|
7
|
+
# Render-tree navigation for components and pages: reach an ancestor — or
|
|
8
|
+
# self — in the Arbre tree by type or tag, to read its identity (class,
|
|
9
|
+
# weft_dom_id, route, page path). This is the "child affects ancestor"
|
|
10
|
+
# affordance: a nested component discovers what it needs to target instead
|
|
11
|
+
# of being hand-fed its parent's identity.
|
|
12
|
+
#
|
|
13
|
+
# Mixed into Weft::Component and Weft::Page (not Context itself — the tree's
|
|
14
|
+
# root has no ancestors to walk). Lives here beside Interception as the
|
|
15
|
+
# other render-context mechanism the two share.
|
|
16
|
+
#
|
|
17
|
+
# Call inside `build`. (There is no instance, and no render tree to walk,
|
|
18
|
+
# inside a verb block.) Lean on the ancestor's identity and params — fixed
|
|
19
|
+
# at construction — rather than state its own `build` may not have set yet,
|
|
20
|
+
# since it is mid-build above you.
|
|
21
|
+
module Traversal
|
|
22
|
+
# The nearest node matching +matcher+, self included, walking up the tree —
|
|
23
|
+
# impedance-matched to the DOM's Element.closest(): include-self,
|
|
24
|
+
# nearest-first, nil if none.
|
|
25
|
+
#
|
|
26
|
+
# closest(Weft::Page) # nearest enclosing page
|
|
27
|
+
# closest(OrdersPanel) # nearest panel of that type
|
|
28
|
+
# closest(Paginatable) # nearest node playing that role
|
|
29
|
+
# closest(:div) # nearest <div> (self included)
|
|
30
|
+
# closest(Weft::Component) { |c| c.params.key?(:order_id) } # refined
|
|
31
|
+
#
|
|
32
|
+
# +matcher+ is a Class/Module (matched is_a?, subclass- and
|
|
33
|
+
# include-inclusive) or a Symbol (matched against tag_name). An optional
|
|
34
|
+
# block refines: a candidate must match the positional AND the block.
|
|
35
|
+
# Returns the matching Arbre node — a component for a class match, a plain
|
|
36
|
+
# element for a tag — or nil.
|
|
37
|
+
def closest(matcher, include_self: true, &refine)
|
|
38
|
+
unless matcher.is_a?(Module) || matcher.is_a?(Symbol)
|
|
39
|
+
raise ArgumentError,
|
|
40
|
+
"closest matcher must be a Class, Module, or Symbol tag name (got #{matcher.class})"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
node = include_self ? self : parent
|
|
44
|
+
while node
|
|
45
|
+
return node if traversal_match?(node, matcher, refine)
|
|
46
|
+
|
|
47
|
+
node = node.parent
|
|
48
|
+
end
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# closest strictly above self (self excluded) — the expressive read for
|
|
53
|
+
# "reach my enclosing X". Does not take include_self; use closest for that.
|
|
54
|
+
def enclosing(matcher, &) = closest(matcher, include_self: false, &)
|
|
55
|
+
|
|
56
|
+
# closest that raises Weft::AncestorNotFound instead of returning nil — for
|
|
57
|
+
# a component that requires the ancestor (pair with dependent!).
|
|
58
|
+
def closest!(matcher, include_self: true, &)
|
|
59
|
+
closest(matcher, include_self: include_self, &) ||
|
|
60
|
+
raise(Weft::AncestorNotFound,
|
|
61
|
+
"no #{matcher.inspect} #{include_self ? 'at or above' : 'above'} #{self.class}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# enclosing that raises instead of returning nil.
|
|
65
|
+
def enclosing!(matcher, &) = closest!(matcher, include_self: false, &)
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def traversal_match?(node, matcher, refine)
|
|
70
|
+
matched =
|
|
71
|
+
if matcher.is_a?(Symbol)
|
|
72
|
+
node.respond_to?(:tag_name) && node.tag_name.to_s == matcher.to_s
|
|
73
|
+
else
|
|
74
|
+
node.is_a?(matcher)
|
|
75
|
+
end
|
|
76
|
+
matched && (refine.nil? || refine.call(node))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "arbre"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
require "weft/action"
|
|
7
|
+
|
|
8
|
+
module Weft
|
|
9
|
+
class Context < Arbre::Context
|
|
10
|
+
# The low-level wiring layer under the kwarg grammar: htmx attribute
|
|
11
|
+
# builders and render-tree lookups. Context::Expansion decides WHICH
|
|
12
|
+
# interaction claims an element; these methods build the actual hx-*
|
|
13
|
+
# hashes and find the components they point at. Mixed into Weft::Context
|
|
14
|
+
# alongside Expansion and Modifiers (whose resolve_swap/resolve_target
|
|
15
|
+
# vocabulary the builders lean on).
|
|
16
|
+
module Wiring
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# On <form> elements, also emit the HTML action and method attributes so
|
|
20
|
+
# non-JS submission works (browser POSTs to the same URL htmx would).
|
|
21
|
+
# Drop hx-vals because the form fields are the submission payload —
|
|
22
|
+
# hx-vals would duplicate or shadow them.
|
|
23
|
+
def augment_for_form(expanded, action, htmx)
|
|
24
|
+
url = htmx["hx-#{action.method}"]
|
|
25
|
+
expanded.except("hx-vals").merge("action" => url, "method" => action.method.to_s)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def navigate_attrs(component, overrides)
|
|
29
|
+
{
|
|
30
|
+
"hx-get" => component.weft_url(**overrides),
|
|
31
|
+
"hx-target" => "##{component.weft_dom_id}",
|
|
32
|
+
"hx-swap" => "outerHTML"
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def preset_wiring(attrs, target_or_url, preset, swap, target)
|
|
37
|
+
htmx = if target_or_url.is_a?(String)
|
|
38
|
+
htmx_get_attrs(target_or_url, swap, target)
|
|
39
|
+
else
|
|
40
|
+
loads_attrs(target_or_url, resolve_with(attrs), swap, target)
|
|
41
|
+
end
|
|
42
|
+
htmx["hx-trigger"] = resolve_trigger(preset[:trigger]) if preset[:trigger]
|
|
43
|
+
htmx
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def loads_attrs(target_class, with_attrs, swap, target)
|
|
47
|
+
htmx_get_attrs(component_url(target_class, with_attrs), swap, target)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def component_url(target_class, with_attrs)
|
|
51
|
+
path = target_class.resolved_component_path
|
|
52
|
+
params = with_attrs.compact
|
|
53
|
+
params.empty? ? path : "#{path}?#{URI.encode_www_form(params)}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def htmx_get_attrs(url, swap, target)
|
|
57
|
+
{
|
|
58
|
+
"hx-get" => url,
|
|
59
|
+
"hx-swap" => Action.resolve_swap(swap),
|
|
60
|
+
"hx-target" => resolve_target(target)
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def find_action_context(action_name)
|
|
65
|
+
el = current_arbre_element
|
|
66
|
+
while el
|
|
67
|
+
return el if el.is_a?(Weft::Component) && el.class.action_for(action_name)
|
|
68
|
+
|
|
69
|
+
el = el.parent
|
|
70
|
+
end
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def find_nearest_component
|
|
75
|
+
el = current_arbre_element
|
|
76
|
+
while el
|
|
77
|
+
return el if el.is_a?(Weft::Component)
|
|
78
|
+
|
|
79
|
+
el = el.parent
|
|
80
|
+
end
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/weft/context.rb
CHANGED
|
@@ -1,184 +1,90 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "arbre"
|
|
4
|
+
|
|
5
|
+
require "weft/context/expansion"
|
|
3
6
|
require "weft/context/interception"
|
|
7
|
+
require "weft/context/modifiers"
|
|
8
|
+
require "weft/context/wiring"
|
|
4
9
|
|
|
5
10
|
module Weft
|
|
6
|
-
# Arbre::Context subclass that intercepts element creation to expand
|
|
7
|
-
#
|
|
11
|
+
# Arbre::Context subclass that intercepts element creation to expand Weft
|
|
12
|
+
# kwargs into htmx attributes — the kwarg vocabulary and its claim rules
|
|
13
|
+
# live in Context::Expansion.
|
|
8
14
|
#
|
|
9
15
|
# Works at every nesting depth because Arbre instance_evals the top-level
|
|
10
16
|
# block, making the Context the receiver for all insert_tag calls throughout
|
|
11
17
|
# the element tree.
|
|
12
|
-
#
|
|
13
|
-
# Supported kwargs:
|
|
14
|
-
# - `action: :name` — expands into htmx attrs for a declared performs/transfers action
|
|
15
|
-
# - `navigate: { key: val }` — expands into htmx GET to self with overridden attrs
|
|
16
|
-
# - `trigger: "event"` — sets hx-trigger (standalone or alongside action:/navigate:)
|
|
17
18
|
class Context < Arbre::Context
|
|
19
|
+
include Expansion
|
|
18
20
|
include Interception
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
21
|
+
include Modifiers
|
|
22
|
+
include Wiring
|
|
23
|
+
|
|
24
|
+
# The render's wire params (query/body/path values), carried on the
|
|
25
|
+
# context so every component in the tree resolves its own declared
|
|
26
|
+
# params at any depth. Assigned before super because Arbre's initialize
|
|
27
|
+
# instance_evals the construction block — the tree builds during super.
|
|
28
|
+
attr_reader :wire_params
|
|
29
|
+
|
|
30
|
+
# Request-scoped overlay values — the accumulated verb-block deltas
|
|
31
|
+
# (action callable returns, includes deltas, recovery injections). In
|
|
32
|
+
# the source stack an overlay entry speaks AS the wire for its key:
|
|
33
|
+
# a value overrides the wire's, a nil clears it (resolution falls
|
|
34
|
+
# below). One universe per request; these are its amendments.
|
|
35
|
+
attr_reader :overlays
|
|
36
|
+
|
|
37
|
+
# A bag for ROOT components to branch from, standing in for the tree
|
|
38
|
+
# ancestor a root doesn't have — how an OOB companion inherits its
|
|
39
|
+
# primary's bag (rich values included) exactly like a child built in
|
|
40
|
+
# the primary's own build.
|
|
41
|
+
attr_reader :branch_bag
|
|
42
|
+
|
|
43
|
+
# The DOM ids this RESPONSE has already spoken for — a Set shared across
|
|
44
|
+
# every context the response builds in, because the primary and each of
|
|
45
|
+
# its companions get their own. An out-of-band swap is addressed by DOM
|
|
46
|
+
# id, so only one fragment per id can land; a root component claims its
|
|
47
|
+
# id as it builds (Component#claim_dom_slot!) and a second claimant
|
|
48
|
+
# abandons its render by throwing SLOT_TAKEN. Absent on renders with
|
|
49
|
+
# nothing to arbitrate, and nothing is claimed then.
|
|
50
|
+
attr_reader :slots
|
|
51
|
+
|
|
52
|
+
# Thrown with the contested DOM id when a root loses a slot. Caught by
|
|
53
|
+
# whoever asked for the render; nothing partial reaches the tree, because
|
|
54
|
+
# Arbre adds a tag to its parent only after the build returns.
|
|
55
|
+
SLOT_TAKEN = :weft_slot_taken
|
|
56
|
+
|
|
57
|
+
# Two positional parameters are Arbre's own signature; the four keywords
|
|
58
|
+
# are Weft's render-scoped channels, each independently optional. That
|
|
59
|
+
# they have grown to four is a fair signal that they want a render-environment
|
|
60
|
+
# object of their own — a change that would touch every render path and
|
|
61
|
+
# a documented constructor, so it belongs with the lifecycle work, not here.
|
|
62
|
+
def initialize(assigns = {}, helpers = nil, wire_params: nil, overlays: nil, # rubocop:disable Metrics/ParameterLists
|
|
63
|
+
branch_bag: nil, slots: nil, &)
|
|
64
|
+
@wire_params = wire_params || {}
|
|
65
|
+
@overlays = overlays || {}
|
|
66
|
+
@branch_bag = branch_bag
|
|
67
|
+
@slots = slots
|
|
68
|
+
super(assigns, helpers, &)
|
|
32
69
|
end
|
|
33
70
|
|
|
34
71
|
# @api private
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
private
|
|
44
|
-
|
|
45
|
-
def expand_action(attrs, for_class: nil)
|
|
46
|
-
action_name = attrs[:action]
|
|
47
|
-
return unless action_name.is_a?(Symbol)
|
|
48
|
-
|
|
49
|
-
component = find_action_context(action_name)
|
|
50
|
-
return unless component
|
|
51
|
-
|
|
52
|
-
action = component.class.action_for(action_name)
|
|
53
|
-
htmx = action.to_htmx_attrs(component)
|
|
54
|
-
expanded = attrs.except(:action).merge(htmx)
|
|
55
|
-
return augment_for_form(expanded, action, htmx) if for_class && for_class <= Arbre::HTML::Form
|
|
56
|
-
|
|
57
|
-
expanded
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# On <form> elements, also emit the HTML action and method attributes so
|
|
61
|
-
# non-JS submission works (browser POSTs to the same URL htmx would).
|
|
62
|
-
# Drop hx-vals because the form fields are the submission payload —
|
|
63
|
-
# hx-vals would duplicate or shadow them.
|
|
64
|
-
def augment_for_form(expanded, action, htmx)
|
|
65
|
-
url = htmx["hx-#{action.method}"]
|
|
66
|
-
expanded.except("hx-vals").merge("action" => url, "method" => action.method.to_s)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def expand_navigate(attrs)
|
|
70
|
-
overrides = attrs[:navigate]
|
|
71
|
-
return unless overrides.is_a?(Hash)
|
|
72
|
-
|
|
73
|
-
component = find_nearest_component
|
|
74
|
-
return unless component
|
|
75
|
-
|
|
76
|
-
attrs.except(:navigate).merge(navigate_attrs(component, overrides))
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def expand_loads(attrs)
|
|
80
|
-
target_class = attrs[:loads]
|
|
81
|
-
return unless target_class.is_a?(Class)
|
|
82
|
-
|
|
83
|
-
validate_loads_kwargs!(attrs)
|
|
84
|
-
remaining = attrs.except(:loads, :swap, :target, :with)
|
|
85
|
-
remaining.merge(loads_attrs(target_class, resolve_with(attrs), attrs[:swap], attrs[:target]))
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def expand_shorthand(attrs)
|
|
89
|
-
shorthand_key, target_class = find_shorthand_kwarg(attrs)
|
|
90
|
-
return unless shorthand_key
|
|
91
|
-
|
|
92
|
-
build_shorthand_attrs(attrs, shorthand_key, target_class, Weft.shorthand(shorthand_key))
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
# A shorthand value is either a target Class (derive the URL from it) or a
|
|
96
|
-
# ready-made URL String (retry-style — the caller already has the URL).
|
|
97
|
-
def find_shorthand_kwarg(attrs)
|
|
98
|
-
attrs.find { |k, v| (v.is_a?(Class) || v.is_a?(String)) && Weft.shorthand(k) }
|
|
72
|
+
# One-shot register for `receives` hand-offs. Interception stages the
|
|
73
|
+
# extracted kwargs here immediately before Arbre constructs the target
|
|
74
|
+
# (insert_tag → build_tag → new); the new instance consumes them during
|
|
75
|
+
# params assembly. Class-checked so a stale staging can never leak into
|
|
76
|
+
# a different component's bag.
|
|
77
|
+
def stage_received(klass, values)
|
|
78
|
+
@staged_received = [klass, values]
|
|
99
79
|
end
|
|
100
80
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
swap = attrs[:swap] || preset[:swap]
|
|
106
|
-
htmx = if target_or_url.is_a?(String)
|
|
107
|
-
htmx_get_attrs(target_or_url, swap, target)
|
|
108
|
-
else
|
|
109
|
-
loads_attrs(target_or_url, resolve_with(attrs), swap, target)
|
|
110
|
-
end
|
|
111
|
-
htmx["hx-trigger"] = resolve_trigger(preset[:trigger]) if preset[:trigger]
|
|
112
|
-
attrs.except(shorthand_key, :swap, :target, :with).merge(htmx)
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def resolve_with(attrs)
|
|
116
|
-
attrs[:with] || find_nearest_component&.attrs&.to_h || {}
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def validate_loads_kwargs!(attrs)
|
|
120
|
-
raise ArgumentError, "loads: requires swap: (e.g., swap: :fill)" unless attrs[:swap]
|
|
121
|
-
raise ArgumentError, "loads: requires target: (e.g., target: :self)" unless attrs[:target]
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def find_action_context(action_name)
|
|
125
|
-
el = current_arbre_element
|
|
126
|
-
while el
|
|
127
|
-
return el if el.is_a?(Weft::Component) && el.class.action_for(action_name)
|
|
128
|
-
|
|
129
|
-
el = el.parent
|
|
130
|
-
end
|
|
131
|
-
nil
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def find_nearest_component
|
|
135
|
-
el = current_arbre_element
|
|
136
|
-
while el
|
|
137
|
-
return el if el.is_a?(Weft::Component)
|
|
138
|
-
|
|
139
|
-
el = el.parent
|
|
140
|
-
end
|
|
141
|
-
nil
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def navigate_attrs(component, overrides)
|
|
145
|
-
{
|
|
146
|
-
"hx-get" => component.weft_url(**overrides),
|
|
147
|
-
"hx-target" => "##{component.weft_id}",
|
|
148
|
-
"hx-swap" => "outerHTML"
|
|
149
|
-
}
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def loads_attrs(target_class, with_attrs, swap, target)
|
|
153
|
-
htmx_get_attrs(component_url(target_class, with_attrs), swap, target)
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def component_url(target_class, with_attrs)
|
|
157
|
-
path = target_class.resolved_component_path
|
|
158
|
-
params = with_attrs.compact
|
|
159
|
-
params.empty? ? path : "#{path}?#{URI.encode_www_form(params)}"
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def htmx_get_attrs(url, swap, target)
|
|
163
|
-
{
|
|
164
|
-
"hx-get" => url,
|
|
165
|
-
"hx-swap" => Action.resolve_swap(swap),
|
|
166
|
-
"hx-target" => resolve_target(target)
|
|
167
|
-
}
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def resolve_target(target)
|
|
171
|
-
case target
|
|
172
|
-
when :self then "this"
|
|
173
|
-
when String then target
|
|
174
|
-
else
|
|
175
|
-
# Arbre element reference — extract #id
|
|
176
|
-
target.respond_to?(:id) ? "##{target.id}" : target.to_s
|
|
177
|
-
end
|
|
178
|
-
end
|
|
81
|
+
# @api private
|
|
82
|
+
def take_received!(klass)
|
|
83
|
+
staged_class, values = @staged_received
|
|
84
|
+
return unless staged_class.equal?(klass)
|
|
179
85
|
|
|
180
|
-
|
|
181
|
-
|
|
86
|
+
@staged_received = nil
|
|
87
|
+
values
|
|
182
88
|
end
|
|
183
89
|
end
|
|
184
90
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "weft/component"
|
|
4
|
+
|
|
3
5
|
module Weft
|
|
4
6
|
module Defaults
|
|
5
7
|
# Gem-default component rendered when an action or partial-render fails
|
|
@@ -7,56 +9,90 @@ module Weft
|
|
|
7
9
|
# exception class and message; non-verbose shows a generic message.
|
|
8
10
|
# Users override by setting Weft.configuration.error_component or by
|
|
9
11
|
# declaring their own `recovers` chain.
|
|
12
|
+
#
|
|
13
|
+
# Doubles as the default recovery frame for SSE push failures: the Router
|
|
14
|
+
# injects :attempts_remaining only on that path, so its presence switches
|
|
15
|
+
# the build into push shape — a countdown notice while the stream retries,
|
|
16
|
+
# then a stopped notice with a reopen button on the final frame.
|
|
10
17
|
class ErrorComponent < Weft::Component
|
|
11
18
|
abstract!
|
|
12
19
|
|
|
13
|
-
# Auto-injected
|
|
20
|
+
# Auto-injected params (opt-in, schema-gated by the Router). The
|
|
14
21
|
# Router populates these at error-handling time on any recovers target
|
|
15
22
|
# that declares them.
|
|
16
|
-
# :
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
# :retry_url is the failing component's GET URL with current params.
|
|
24
|
+
# :attempts_remaining is the push-path countdown (nil on HTTP paths).
|
|
25
|
+
# (Identity is not among them — the Router stamps the failing
|
|
26
|
+
# component's DOM id onto every recovery fragment itself.)
|
|
27
|
+
param :component_tag, type: :string
|
|
28
|
+
param :exception
|
|
29
|
+
param :request_path, type: :string
|
|
30
|
+
param :status_code, type: :integer
|
|
31
|
+
param :retry_url, type: :string
|
|
32
|
+
param :attempts_remaining, type: :integer
|
|
25
33
|
|
|
26
34
|
STYLE = "padding:1rem; border:1px solid #fca5a5; border-radius:6px; " \
|
|
27
35
|
"background:#fef2f2; color:#991b1b; font-size:0.875rem"
|
|
36
|
+
HEADING_STYLE = "font-weight:600; margin-bottom:0.5rem"
|
|
28
37
|
MONO_STYLE = "margin-bottom:0.5rem; font-family:monospace; font-size:0.8rem"
|
|
29
38
|
BUTTON_STYLE = "padding:0.25rem 0.75rem; border:1px solid #b91c1c; border-radius:4px; " \
|
|
30
39
|
"background:#fff; color:#b91c1c; font-size:0.75rem; cursor:pointer"
|
|
31
40
|
|
|
32
41
|
def build(attributes = {})
|
|
33
42
|
super
|
|
43
|
+
return build_push_error if push_context?
|
|
44
|
+
|
|
34
45
|
add_class "weft-error"
|
|
35
46
|
set_attribute "style", STYLE
|
|
36
47
|
|
|
37
|
-
div(style:
|
|
38
|
-
render_verbose if
|
|
39
|
-
render_retry_button if @
|
|
48
|
+
div(style: HEADING_STYLE) { text_node "Something went wrong" }
|
|
49
|
+
render_verbose if verbose?
|
|
50
|
+
render_retry_button if @params.retry_url
|
|
40
51
|
end
|
|
41
52
|
|
|
42
|
-
#
|
|
43
|
-
# :
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
# Adopt the failing component's wrapper tag when the Router injected
|
|
54
|
+
# :component_tag — a div swapped into a <tr>'s position is invalid
|
|
55
|
+
# table content; a <tr> error row is not.
|
|
56
|
+
def tag_name
|
|
57
|
+
@params.component_tag || super
|
|
46
58
|
end
|
|
47
59
|
|
|
48
60
|
private
|
|
49
61
|
|
|
62
|
+
def push_context? = !@params.attempts_remaining.nil?
|
|
63
|
+
def verbose? = Weft.configuration.verbose_error_pages && @params.exception
|
|
64
|
+
|
|
65
|
+
# Push frames ship children only — the persistent wrapper client-side
|
|
66
|
+
# belongs to the original component — so the visual box must be an inner
|
|
67
|
+
# element; anything set on the wrapper would never reach the browser.
|
|
68
|
+
def build_push_error
|
|
69
|
+
stopped = @params.attempts_remaining.zero?
|
|
70
|
+
div(class: "weft-error", style: STYLE) do
|
|
71
|
+
div(style: HEADING_STYLE) do
|
|
72
|
+
text_node stopped ? "Live updates stopped" : "Live updates interrupted — retrying"
|
|
73
|
+
end
|
|
74
|
+
render_verbose if verbose?
|
|
75
|
+
render_reopen_button if stopped && @params.retry_url
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
50
79
|
def render_verbose
|
|
51
|
-
exc = @
|
|
80
|
+
exc = @params.exception
|
|
52
81
|
div(style: MONO_STYLE) { text_node "#{exc.class}: #{exc.message}" }
|
|
53
82
|
end
|
|
54
83
|
|
|
55
84
|
# Retry by re-issuing a GET to refresh the failing component. The :retry
|
|
56
|
-
#
|
|
57
|
-
# .weft-error box), so it works
|
|
85
|
+
# preset supplies the htmx wiring (outerHTML-swap the closest
|
|
86
|
+
# .weft-error box), so it works wherever the fragment happens to land.
|
|
58
87
|
def render_retry_button
|
|
59
|
-
button "Retry", retry: @
|
|
88
|
+
button "Retry", retry: @params.retry_url, style: BUTTON_STYLE
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Resume by re-fetching the component whole: the fresh wrapper's
|
|
92
|
+
# sse-connect reopens the stream with a full attempts budget. The
|
|
93
|
+
# :reopen_stream preset targets the dead wrapper itself.
|
|
94
|
+
def render_reopen_button
|
|
95
|
+
button "Resume live updates", reopen_stream: @params.retry_url, style: BUTTON_STYLE
|
|
60
96
|
end
|
|
61
97
|
end
|
|
62
98
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "weft/page"
|
|
4
|
+
|
|
3
5
|
module Weft
|
|
4
6
|
module Defaults
|
|
5
7
|
# Gem-default full-document page rendered for traditional (non-htmx)
|
|
@@ -8,19 +10,19 @@ module Weft
|
|
|
8
10
|
class ErrorPage < Weft::Page
|
|
9
11
|
self.page_path = "/_weft/error"
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
param :exception
|
|
14
|
+
param :request_path, type: :string
|
|
15
|
+
param :status_code, type: :integer
|
|
16
|
+
|
|
17
|
+
title "Error"
|
|
14
18
|
|
|
19
|
+
# The component reads the same request the page did — recovery values
|
|
20
|
+
# ride as overlays and reach every depth — so handing them over as
|
|
21
|
+
# builder kwargs would only render them as HTML attributes on the error
|
|
22
|
+
# box, exception message and all.
|
|
15
23
|
def build(attributes = {})
|
|
16
|
-
attributes[:title] ||= "Error"
|
|
17
24
|
super
|
|
18
|
-
insert_tag(
|
|
19
|
-
Weft::Defaults::ErrorComponent,
|
|
20
|
-
exception: @attrs.exception,
|
|
21
|
-
request_path: @attrs.request_path,
|
|
22
|
-
status_code: @attrs.status_code
|
|
23
|
-
)
|
|
25
|
+
insert_tag(Weft::Defaults::ErrorComponent)
|
|
24
26
|
end
|
|
25
27
|
end
|
|
26
28
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "weft/component"
|
|
4
|
+
|
|
3
5
|
module Weft
|
|
4
6
|
module Defaults
|
|
5
7
|
# Gem-default component rendered when a routing miss falls through to
|
|
@@ -8,12 +10,12 @@ module Weft
|
|
|
8
10
|
class NotFoundComponent < Weft::Component
|
|
9
11
|
abstract!
|
|
10
12
|
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
# Auto-injected params, opt-in for parity with ErrorComponent. Identity
|
|
14
|
+
# is not among them — the Router stamps the failing component's DOM id
|
|
15
|
+
# onto every recovery fragment itself.
|
|
16
|
+
param :component_tag, type: :string
|
|
17
|
+
param :request_path, type: :string
|
|
18
|
+
param :status_code, type: :integer
|
|
17
19
|
|
|
18
20
|
STYLE = "padding:1rem; border:1px solid #cbd5e1; border-radius:6px; " \
|
|
19
21
|
"background:#f8fafc; color:#0f172a; font-size:0.875rem"
|
|
@@ -25,19 +27,19 @@ module Weft
|
|
|
25
27
|
set_attribute "style", STYLE
|
|
26
28
|
|
|
27
29
|
div(style: "font-weight:600") { text_node "Not found" }
|
|
28
|
-
render_verbose if Weft.configuration.verbose_error_pages && @
|
|
30
|
+
render_verbose if Weft.configuration.verbose_error_pages && @params.request_path
|
|
29
31
|
end
|
|
30
32
|
|
|
31
|
-
#
|
|
32
|
-
# :
|
|
33
|
-
def
|
|
34
|
-
@
|
|
33
|
+
# Adopt the failing component's wrapper tag when the Router injected
|
|
34
|
+
# :component_tag (see ErrorComponent#tag_name).
|
|
35
|
+
def tag_name
|
|
36
|
+
@params.component_tag || super
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
private
|
|
38
40
|
|
|
39
41
|
def render_verbose
|
|
40
|
-
div(style: MONO_STYLE) { text_node @
|
|
42
|
+
div(style: MONO_STYLE) { text_node @params.request_path.to_s }
|
|
41
43
|
end
|
|
42
44
|
end
|
|
43
45
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "weft/page"
|
|
4
|
+
|
|
3
5
|
module Weft
|
|
4
6
|
module Defaults
|
|
5
7
|
# Gem-default full-document page rendered for traditional (non-htmx)
|
|
@@ -8,17 +10,16 @@ module Weft
|
|
|
8
10
|
class NotFoundPage < Weft::Page
|
|
9
11
|
self.page_path = "/_weft/not_found"
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
param :request_path, type: :string
|
|
14
|
+
param :status_code, type: :integer
|
|
15
|
+
|
|
16
|
+
title "Not found"
|
|
13
17
|
|
|
18
|
+
# See ErrorPage#build: the component resolves these from the same
|
|
19
|
+
# request, so passing them here would only paint them onto the wrapper.
|
|
14
20
|
def build(attributes = {})
|
|
15
|
-
attributes[:title] ||= "Not found"
|
|
16
21
|
super
|
|
17
|
-
insert_tag(
|
|
18
|
-
Weft::Defaults::NotFoundComponent,
|
|
19
|
-
request_path: @attrs.request_path,
|
|
20
|
-
status_code: @attrs.status_code
|
|
21
|
-
)
|
|
22
|
+
insert_tag(Weft::Defaults::NotFoundComponent)
|
|
22
23
|
end
|
|
23
24
|
end
|
|
24
25
|
end
|