view_component_reducible 0.1.1 → 0.1.3
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/README.md +2 -2
- data/lib/view_component_reducible/adapter/hidden_field.rb +6 -5
- data/lib/view_component_reducible/adapter/session.rb +7 -7
- data/lib/view_component_reducible/component.rb +52 -4
- data/lib/view_component_reducible/configuration.rb +2 -2
- data/lib/view_component_reducible/dispatch.rb +2 -2
- data/lib/view_component_reducible/dispatch_controller.rb +7 -7
- data/lib/view_component_reducible/engine.rb +1 -5
- data/lib/view_component_reducible/helpers.rb +21 -11
- data/lib/view_component_reducible/msg.rb +65 -5
- data/lib/view_component_reducible/railtie.rb +2 -2
- data/lib/view_component_reducible/runtime.rb +71 -26
- data/lib/view_component_reducible/state/dsl.rb +2 -10
- data/lib/view_component_reducible/state/envelope.rb +7 -8
- data/lib/view_component_reducible/state/schema.rb +40 -18
- data/lib/view_component_reducible/version.rb +1 -1
- data/lib/view_component_reducible.rb +16 -16
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5505b895dc07055d3d09ad5e466316aadab92321fd2b8a71c3889cc1284059f2
|
|
4
|
+
data.tar.gz: 66757165853350bb660d16605c935ec5072eb01aedf4dfaf62419ab4d1af5439
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a5a98ecba5e6ac762086bce05b77409360708c91489d6bbd23bba626e9cc0f6ed1f06ee138b90bcb44c68c06e4b35f4474365ea4b5baae3600283e261e1b09aa
|
|
7
|
+
data.tar.gz: b56697564e122c374d7291cd1955678af2fb79ca307351b74f4c39043e7912bebab33d4f2ae17d26644a46a42a6f9781267be0db27601b5bbad14affe7fa9a3d
|
data/README.md
CHANGED
|
@@ -9,7 +9,7 @@ The only thing you add in Rails is a single endpoint:
|
|
|
9
9
|
mount ViewComponentReducible::Engine, at: "/vcr"
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
Everything else
|
|
12
|
+
Everything else is **reducible** to ViewComponent—no extra endpoints, controllers, WebSockets, or JS frameworks required.
|
|
13
13
|
|
|
14
14
|
view_component_reducible brings reducer-based state transitions
|
|
15
|
-
to Rails ViewComponent, inspired by TEA (The Elm Architecture).
|
|
15
|
+
to Rails ViewComponent, inspired by TEA (The Elm Architecture).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
3
|
+
require 'active_support'
|
|
4
|
+
require 'active_support/message_verifier'
|
|
5
5
|
|
|
6
6
|
module ViewComponentReducible
|
|
7
7
|
module Adapter
|
|
@@ -9,20 +9,21 @@ module ViewComponentReducible
|
|
|
9
9
|
class HiddenField < Base
|
|
10
10
|
# @return [ActiveSupport::MessageVerifier]
|
|
11
11
|
def verifier
|
|
12
|
-
@verifier ||= ActiveSupport::MessageVerifier.new(@secret, digest:
|
|
12
|
+
@verifier ||= ActiveSupport::MessageVerifier.new(@secret, digest: 'SHA256', serializer: JSON)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
# @param envelope [Hash]
|
|
16
16
|
# @param request [ActionDispatch::Request]
|
|
17
17
|
# @return [String]
|
|
18
|
-
def dump(envelope, request:)
|
|
18
|
+
def dump(envelope, request: nil)
|
|
19
|
+
_ = request
|
|
19
20
|
verifier.generate(envelope)
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
# @param request [ActionDispatch::Request]
|
|
23
24
|
# @return [Hash]
|
|
24
25
|
def load(request:)
|
|
25
|
-
signed = request.params.fetch(
|
|
26
|
+
signed = request.params.fetch('vcr_state')
|
|
26
27
|
verifier.verify(signed)
|
|
27
28
|
end
|
|
28
29
|
end
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
3
|
+
require 'active_support'
|
|
4
|
+
require 'active_support/message_verifier'
|
|
5
|
+
require 'securerandom'
|
|
6
6
|
|
|
7
7
|
module ViewComponentReducible
|
|
8
8
|
module Adapter
|
|
@@ -10,7 +10,7 @@ module ViewComponentReducible
|
|
|
10
10
|
class Session < Base
|
|
11
11
|
# @return [ActiveSupport::MessageVerifier]
|
|
12
12
|
def verifier
|
|
13
|
-
@verifier ||= ActiveSupport::MessageVerifier.new(@secret, digest:
|
|
13
|
+
@verifier ||= ActiveSupport::MessageVerifier.new(@secret, digest: 'SHA256', serializer: JSON)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
# @param envelope [Hash]
|
|
@@ -19,15 +19,15 @@ module ViewComponentReducible
|
|
|
19
19
|
def dump(envelope, request:)
|
|
20
20
|
key = SecureRandom.hex(16)
|
|
21
21
|
request.session["vcr:#{key}"] = envelope
|
|
22
|
-
verifier.generate({
|
|
22
|
+
verifier.generate({ 'k' => key })
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
# @param request [ActionDispatch::Request]
|
|
26
26
|
# @return [Hash]
|
|
27
27
|
def load(request:)
|
|
28
|
-
signed = request.params.fetch(
|
|
28
|
+
signed = request.params.fetch('vcr_state')
|
|
29
29
|
payload = verifier.verify(signed)
|
|
30
|
-
key = payload.fetch(
|
|
30
|
+
key = payload.fetch('k')
|
|
31
31
|
request.session.fetch("vcr:#{key}")
|
|
32
32
|
end
|
|
33
33
|
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
3
6
|
module ViewComponentReducible
|
|
4
7
|
# Include to enable the component state DSL and helpers.
|
|
5
8
|
module Component
|
|
@@ -27,13 +30,12 @@ module ViewComponentReducible
|
|
|
27
30
|
end
|
|
28
31
|
|
|
29
32
|
# Build state hash for rendering from the envelope.
|
|
30
|
-
# @return [Hash{String=>
|
|
33
|
+
# @return [Hash{String=>Object}]
|
|
31
34
|
def vcr_state
|
|
32
|
-
return {
|
|
35
|
+
return {} if vcr_envelope.nil?
|
|
33
36
|
|
|
34
37
|
schema = self.class.vcr_state_schema
|
|
35
|
-
|
|
36
|
-
{ "data" => data, "meta" => meta }
|
|
38
|
+
schema.build(vcr_envelope['data'])
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
# Optional DOM target id for updates.
|
|
@@ -43,6 +45,24 @@ module ViewComponentReducible
|
|
|
43
45
|
"vcr:#{self.class.vcr_id}:#{path}"
|
|
44
46
|
end
|
|
45
47
|
|
|
48
|
+
# Render component markup wrapped in a VCR boundary when available.
|
|
49
|
+
# @param view_context [ActionView::Base]
|
|
50
|
+
# @return [String]
|
|
51
|
+
def render_in(view_context, &block)
|
|
52
|
+
ensure_vcr_state(view_context) if vcr_envelope.nil?
|
|
53
|
+
|
|
54
|
+
path = vcr_envelope && vcr_envelope['path']
|
|
55
|
+
previous_path = view_context.instance_variable_get(:@vcr_current_path)
|
|
56
|
+
view_context.instance_variable_set(:@vcr_current_path, path)
|
|
57
|
+
|
|
58
|
+
rendered = super
|
|
59
|
+
return rendered if path.nil? || path.to_s.empty?
|
|
60
|
+
|
|
61
|
+
inject_vcr_path(rendered, path)
|
|
62
|
+
ensure
|
|
63
|
+
view_context.instance_variable_set(:@vcr_current_path, previous_path)
|
|
64
|
+
end
|
|
65
|
+
|
|
46
66
|
module ClassMethods
|
|
47
67
|
# Stable component identifier for envelopes.
|
|
48
68
|
# @return [String]
|
|
@@ -50,5 +70,33 @@ module ViewComponentReducible
|
|
|
50
70
|
name.to_s
|
|
51
71
|
end
|
|
52
72
|
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def inject_vcr_path(rendered, path)
|
|
77
|
+
fragment = Nokogiri::HTML::DocumentFragment.parse(rendered.to_s)
|
|
78
|
+
root = fragment.children.find(&:element?)
|
|
79
|
+
return rendered if root.nil?
|
|
80
|
+
|
|
81
|
+
root['data-vcr-path'] = path
|
|
82
|
+
html = fragment.to_html
|
|
83
|
+
html.respond_to?(:html_safe) ? html.html_safe : html
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def ensure_vcr_state(view_context)
|
|
87
|
+
return unless view_context.respond_to?(:controller)
|
|
88
|
+
|
|
89
|
+
controller = view_context.controller
|
|
90
|
+
return unless controller.respond_to?(:request)
|
|
91
|
+
|
|
92
|
+
envelope = State::Envelope.initial(self.class, path: next_root_path(view_context))
|
|
93
|
+
adapter = ViewComponentReducible.config.adapter_for(controller)
|
|
94
|
+
@vcr_state_token = adapter.dump(envelope, request: controller.request)
|
|
95
|
+
@vcr_envelope = envelope
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def next_root_path(_view_context)
|
|
99
|
+
"root/#{SecureRandom.uuid}"
|
|
100
|
+
end
|
|
53
101
|
end
|
|
54
102
|
end
|
|
@@ -15,9 +15,9 @@ module ViewComponentReducible
|
|
|
15
15
|
# @param controller [ActionController::Base]
|
|
16
16
|
# @param adapter_class [Class, nil]
|
|
17
17
|
# @return [ViewComponentReducible::Adapter::Base]
|
|
18
|
-
def adapter_for(
|
|
18
|
+
def adapter_for(_controller, adapter_class: nil)
|
|
19
19
|
resolved_secret = secret || default_secret
|
|
20
|
-
raise
|
|
20
|
+
raise 'ViewComponentReducible secret is missing' if resolved_secret.nil?
|
|
21
21
|
|
|
22
22
|
(adapter_class || adapter).new(secret: resolved_secret)
|
|
23
23
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'erb'
|
|
4
4
|
|
|
5
5
|
module ViewComponentReducible
|
|
6
6
|
# Helpers for dispatch responses.
|
|
@@ -24,7 +24,7 @@ module ViewComponentReducible
|
|
|
24
24
|
SCRIPT
|
|
25
25
|
|
|
26
26
|
injection = meta + script
|
|
27
|
-
html.include?(
|
|
27
|
+
html.include?('</head>') ? html.sub('</head>', "#{injection}</head>") : (injection + html)
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'action_controller'
|
|
4
4
|
|
|
5
5
|
module ViewComponentReducible
|
|
6
6
|
# Rails controller entry for dispatch requests.
|
|
@@ -21,7 +21,7 @@ module ViewComponentReducible
|
|
|
21
21
|
adapter = ViewComponentReducible.config.adapter_for(self, adapter_class: adapter_class)
|
|
22
22
|
envelope = adapter.load(request:)
|
|
23
23
|
msg = ViewComponentReducible::Msg.from_params(params)
|
|
24
|
-
target_path = params.fetch(
|
|
24
|
+
target_path = params.fetch('vcr_target_path', envelope['path'])
|
|
25
25
|
|
|
26
26
|
runtime = ViewComponentReducible::Runtime.new
|
|
27
27
|
new_envelope, html = runtime.call(
|
|
@@ -32,15 +32,15 @@ module ViewComponentReducible
|
|
|
32
32
|
)
|
|
33
33
|
|
|
34
34
|
signed = adapter.dump(new_envelope, request:)
|
|
35
|
-
if params[
|
|
35
|
+
if params['vcr_partial'] == '1'
|
|
36
36
|
partial_html = runtime.render_target(envelope: new_envelope, target_path:, controller: self)
|
|
37
|
-
response.set_header(
|
|
38
|
-
render html: partial_html, content_type:
|
|
37
|
+
response.set_header('X-VCR-State', signed)
|
|
38
|
+
render html: partial_html, content_type: 'text/html'
|
|
39
39
|
else
|
|
40
|
-
render html: ViewComponentReducible::Dispatch.inject_state(html, signed), content_type:
|
|
40
|
+
render html: ViewComponentReducible::Dispatch.inject_state(html, signed), content_type: 'text/html'
|
|
41
41
|
end
|
|
42
42
|
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
43
|
-
render status: 400, plain:
|
|
43
|
+
render status: 400, plain: 'Invalid state signature'
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
46
|
end
|
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'rails/engine'
|
|
4
4
|
|
|
5
5
|
module ViewComponentReducible
|
|
6
6
|
# Rails engine for mounting dispatch routes.
|
|
7
7
|
class Engine < ::Rails::Engine
|
|
8
8
|
isolate_namespace ViewComponentReducible
|
|
9
|
-
|
|
10
|
-
routes.draw do
|
|
11
|
-
post "/dispatch", to: "dispatch#call"
|
|
12
|
-
end
|
|
13
9
|
end
|
|
14
10
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'json'
|
|
4
4
|
|
|
5
5
|
module ViewComponentReducible
|
|
6
6
|
# View helpers for dispatching messages to the VCR endpoint.
|
|
@@ -17,20 +17,21 @@ module ViewComponentReducible
|
|
|
17
17
|
# @param state [String] signed state token
|
|
18
18
|
# @param msg_type [String]
|
|
19
19
|
# @param msg_payload [Hash, String]
|
|
20
|
-
# @param target_path [String]
|
|
20
|
+
# @param target_path [String, nil]
|
|
21
21
|
# @param url [String]
|
|
22
22
|
# @yield block for the form body (e.g., submit button)
|
|
23
23
|
# @return [String]
|
|
24
|
-
def vcr_dispatch_form(state:, msg_type:, msg_payload: {}, target_path:
|
|
24
|
+
def vcr_dispatch_form(state:, msg_type:, msg_payload: {}, target_path: nil, url: '/vcr/dispatch', &block)
|
|
25
25
|
payload = msg_payload.is_a?(String) ? msg_payload : JSON.generate(msg_payload)
|
|
26
|
+
resolved_target = target_path || vcr_envelope_path || instance_variable_get(:@vcr_current_path) || 'root'
|
|
26
27
|
|
|
27
28
|
form_tag(url, method: :post, data: { vcr_form: true }) do
|
|
28
29
|
body = [
|
|
29
|
-
hidden_field_tag(
|
|
30
|
-
hidden_field_tag(
|
|
31
|
-
hidden_field_tag(
|
|
32
|
-
hidden_field_tag(
|
|
33
|
-
(block_given? ? capture(&block) :
|
|
30
|
+
hidden_field_tag('vcr_state', state),
|
|
31
|
+
hidden_field_tag('vcr_msg_type', msg_type),
|
|
32
|
+
hidden_field_tag('vcr_msg_payload', payload),
|
|
33
|
+
hidden_field_tag('vcr_target_path', resolved_target),
|
|
34
|
+
(block_given? ? capture(&block) : '')
|
|
34
35
|
]
|
|
35
36
|
safe_join(body)
|
|
36
37
|
end
|
|
@@ -71,9 +72,12 @@ module ViewComponentReducible
|
|
|
71
72
|
current.replaceWith(newNode);
|
|
72
73
|
}
|
|
73
74
|
if (payload.state) {
|
|
74
|
-
document.
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
var boundary = document.querySelector('[data-vcr-path="' + targetPath + '"]');
|
|
76
|
+
if (boundary) {
|
|
77
|
+
boundary.querySelectorAll('input[name="vcr_state"]').forEach(function(input) {
|
|
78
|
+
input.value = payload.state;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
77
81
|
}
|
|
78
82
|
});
|
|
79
83
|
});
|
|
@@ -81,5 +85,11 @@ module ViewComponentReducible
|
|
|
81
85
|
JS
|
|
82
86
|
content_tag(:script, js.html_safe)
|
|
83
87
|
end
|
|
88
|
+
|
|
89
|
+
def vcr_envelope_path
|
|
90
|
+
return unless respond_to?(:vcr_envelope) && vcr_envelope
|
|
91
|
+
|
|
92
|
+
vcr_envelope['path']
|
|
93
|
+
end
|
|
84
94
|
end
|
|
85
95
|
end
|
|
@@ -1,18 +1,78 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'json'
|
|
4
4
|
|
|
5
5
|
module ViewComponentReducible
|
|
6
6
|
# Message payload sent from the client.
|
|
7
7
|
Msg = Struct.new(:type, :payload, keyword_init: true) do
|
|
8
|
+
# Enable pattern matching with normalized symbol types.
|
|
9
|
+
# @param keys [Array<Symbol>, nil]
|
|
10
|
+
# @return [Hash{Symbol=>Object}]
|
|
11
|
+
def deconstruct_keys(keys)
|
|
12
|
+
payload_hash = { type: normalized_type, payload: payload }
|
|
13
|
+
keys ? payload_hash.slice(*keys) : payload_hash
|
|
14
|
+
end
|
|
15
|
+
|
|
8
16
|
# Build a Msg from request params.
|
|
9
17
|
# @param params [Hash]
|
|
10
18
|
# @return [ViewComponentReducible::Msg]
|
|
11
19
|
def self.from_params(params)
|
|
12
|
-
type = params.fetch(
|
|
13
|
-
payload_json = params[
|
|
14
|
-
payload = payload_json && payload_json !=
|
|
15
|
-
|
|
20
|
+
type = params.fetch('vcr_msg_type')
|
|
21
|
+
payload_json = params['vcr_msg_payload']
|
|
22
|
+
payload = payload_json && payload_json != '' ? JSON.parse(payload_json) : {}
|
|
23
|
+
build(type:, payload:)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Build a Msg with a normalized payload object.
|
|
27
|
+
# @param type [String, Symbol]
|
|
28
|
+
# @param payload [Object, nil]
|
|
29
|
+
# @return [ViewComponentReducible::Msg]
|
|
30
|
+
def self.build(type:, payload: nil)
|
|
31
|
+
return new(type:, payload:) if payload.is_a?(Data)
|
|
32
|
+
|
|
33
|
+
normalized = normalize_type(type)
|
|
34
|
+
new(type:, payload: self::Payload.from_hash(normalized, payload))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param type [String, Symbol]
|
|
38
|
+
# @return [Symbol]
|
|
39
|
+
def self.normalize_type(type)
|
|
40
|
+
type.to_s
|
|
41
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
42
|
+
.tr('-', '_')
|
|
43
|
+
.downcase
|
|
44
|
+
.to_sym
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def normalized_type
|
|
50
|
+
self.class.normalize_type(type)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class Msg
|
|
55
|
+
module Payload
|
|
56
|
+
Empty = Data.define
|
|
57
|
+
Value = Data.define(:value)
|
|
58
|
+
|
|
59
|
+
def self.from_hash(_type, payload)
|
|
60
|
+
payload_hash = payload.is_a?(Hash) ? payload.transform_keys(&:to_s) : nil
|
|
61
|
+
|
|
62
|
+
return Empty.new if payload.nil? || (payload.respond_to?(:empty?) && payload.empty?)
|
|
63
|
+
return Value.new(value: payload) unless payload_hash
|
|
64
|
+
|
|
65
|
+
build_generic(payload_hash)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.build_generic(payload_hash)
|
|
69
|
+
return Empty.new if payload_hash.empty?
|
|
70
|
+
|
|
71
|
+
keys = payload_hash.keys.map(&:to_sym)
|
|
72
|
+
klass = Data.define(*keys)
|
|
73
|
+
values = keys.to_h { |key| [key, payload_hash[key.to_s]] }
|
|
74
|
+
klass.new(**values)
|
|
75
|
+
end
|
|
16
76
|
end
|
|
17
77
|
end
|
|
18
78
|
end
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'rails/railtie'
|
|
4
4
|
|
|
5
5
|
module ViewComponentReducible
|
|
6
6
|
# Railtie for wiring helpers into ActionView.
|
|
7
7
|
class Railtie < ::Rails::Railtie
|
|
8
|
-
initializer
|
|
8
|
+
initializer 'view_component_reducible.helpers' do
|
|
9
9
|
ActiveSupport.on_load(:action_view) do
|
|
10
10
|
include ViewComponentReducible::Helpers
|
|
11
11
|
end
|
|
@@ -11,7 +11,7 @@ module ViewComponentReducible
|
|
|
11
11
|
# @param controller [ActionController::Base]
|
|
12
12
|
# @return [Array<Hash, String>] [new_envelope, html]
|
|
13
13
|
def call(envelope:, msg:, target_path:, controller:)
|
|
14
|
-
root_klass = ViewComponentReducible.registry.fetch(envelope[
|
|
14
|
+
root_klass = ViewComponentReducible.registry.fetch(envelope['root'])
|
|
15
15
|
new_env = deep_dup(envelope)
|
|
16
16
|
|
|
17
17
|
new_env = dispatch_to_path(root_klass, new_env, msg, target_path, controller)
|
|
@@ -25,7 +25,7 @@ module ViewComponentReducible
|
|
|
25
25
|
# @param controller [ActionController::Base]
|
|
26
26
|
# @return [String]
|
|
27
27
|
def render_target(envelope:, target_path:, controller:)
|
|
28
|
-
root_klass = ViewComponentReducible.registry.fetch(envelope[
|
|
28
|
+
root_klass = ViewComponentReducible.registry.fetch(envelope['root'])
|
|
29
29
|
component_klass, env = find_env_and_class(root_klass, envelope, target_path)
|
|
30
30
|
controller.view_context.render(component_klass.new(vcr_envelope: env))
|
|
31
31
|
end
|
|
@@ -33,12 +33,12 @@ module ViewComponentReducible
|
|
|
33
33
|
private
|
|
34
34
|
|
|
35
35
|
def dispatch_to_path(root_klass, env, msg, target_path, controller)
|
|
36
|
-
if target_path == env[
|
|
36
|
+
if target_path == env['path']
|
|
37
37
|
apply_reducer(root_klass, env, msg, controller)
|
|
38
38
|
else
|
|
39
|
-
child = env[
|
|
40
|
-
child_klass = ViewComponentReducible.registry.fetch(child[
|
|
41
|
-
env[
|
|
39
|
+
child = env['children'].fetch(target_path) { raise KeyError, "Unknown path: #{target_path}" }
|
|
40
|
+
child_klass = ViewComponentReducible.registry.fetch(child['root'])
|
|
41
|
+
env['children'][target_path] = dispatch_to_path(child_klass, child, msg, target_path, controller)
|
|
42
42
|
env
|
|
43
43
|
end
|
|
44
44
|
end
|
|
@@ -46,38 +46,55 @@ module ViewComponentReducible
|
|
|
46
46
|
def apply_reducer(component_klass, env, msg, controller)
|
|
47
47
|
component = component_klass.new(vcr_envelope: env)
|
|
48
48
|
schema = component_klass.vcr_state_schema
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
state = schema.build_data(env['data'])
|
|
50
|
+
|
|
51
|
+
reducer_result = component.reduce(state, msg)
|
|
52
|
+
reduced_state, reducer_effects = case reducer_result
|
|
53
|
+
in state_only unless reducer_result.is_a?(Array)
|
|
54
|
+
[state_only, []]
|
|
55
|
+
in [state_only]
|
|
56
|
+
[state_only, []]
|
|
57
|
+
in [state_only, *effects]
|
|
58
|
+
[state_only, effects]
|
|
59
|
+
end
|
|
60
|
+
env['data'] = normalize_state(reduced_state, schema)
|
|
61
|
+
effects = normalize_effects(reducer_effects) + normalize_effects(build_effects(component, schema, env['data'],
|
|
62
|
+
msg))
|
|
55
63
|
|
|
56
64
|
run_effects(component_klass, env, effects, controller)
|
|
57
65
|
end
|
|
58
66
|
|
|
59
67
|
def run_effects(component_klass, env, effects, controller)
|
|
60
|
-
|
|
68
|
+
effects_queue = normalize_effects(effects).dup
|
|
69
|
+
return env if effects_queue.empty?
|
|
61
70
|
|
|
62
|
-
effects_queue = effects.dup
|
|
63
71
|
steps = 0
|
|
64
72
|
|
|
65
73
|
while (eff = effects_queue.shift)
|
|
66
74
|
steps += 1
|
|
67
|
-
raise
|
|
75
|
+
raise 'Too many effect steps' if steps > MAX_EFFECT_STEPS
|
|
68
76
|
|
|
69
|
-
follow_msg = eff
|
|
77
|
+
follow_msg = resolve_effect_msg(eff, controller, env)
|
|
70
78
|
next unless follow_msg
|
|
79
|
+
raise ArgumentError, 'Effect must return a Msg' unless follow_msg.is_a?(Msg)
|
|
71
80
|
|
|
72
81
|
component = component_klass.new(vcr_envelope: env)
|
|
73
82
|
schema = component_klass.vcr_state_schema
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
state = schema.build_data(env['data'])
|
|
84
|
+
|
|
85
|
+
reducer_result = component.reduce(state, follow_msg)
|
|
86
|
+
reduced_state, reducer_effects = case reducer_result
|
|
87
|
+
in state_only unless reducer_result.is_a?(Array)
|
|
88
|
+
[state_only, []]
|
|
89
|
+
in [state_only]
|
|
90
|
+
[state_only, []]
|
|
91
|
+
in [state_only, *effects]
|
|
92
|
+
[state_only, effects]
|
|
93
|
+
end
|
|
94
|
+
env['data'] = normalize_state(reduced_state, schema)
|
|
95
|
+
new_effects = normalize_effects(reducer_effects) + normalize_effects(build_effects(component, schema,
|
|
96
|
+
env['data'], follow_msg))
|
|
97
|
+
effects_queue.concat(new_effects)
|
|
81
98
|
end
|
|
82
99
|
|
|
83
100
|
env
|
|
@@ -88,13 +105,41 @@ module ViewComponentReducible
|
|
|
88
105
|
end
|
|
89
106
|
|
|
90
107
|
def find_env_and_class(component_klass, env, target_path)
|
|
91
|
-
return [component_klass, env] if target_path == env[
|
|
108
|
+
return [component_klass, env] if target_path == env['path']
|
|
92
109
|
|
|
93
|
-
child = env[
|
|
94
|
-
child_klass = ViewComponentReducible.registry.fetch(child[
|
|
110
|
+
child = env['children'].fetch(target_path) { raise KeyError, "Unknown path: #{target_path}" }
|
|
111
|
+
child_klass = ViewComponentReducible.registry.fetch(child['root'])
|
|
95
112
|
find_env_and_class(child_klass, child, target_path)
|
|
96
113
|
end
|
|
97
114
|
|
|
115
|
+
def normalize_state(state, schema)
|
|
116
|
+
if state.is_a?(schema.data_class)
|
|
117
|
+
state.to_h.transform_keys(&:to_s)
|
|
118
|
+
elsif state.is_a?(Hash)
|
|
119
|
+
schema.build(state)
|
|
120
|
+
else
|
|
121
|
+
raise ArgumentError, "Reducer must return a Hash or #{schema.data_class}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def build_effects(component, schema, state_hash, msg)
|
|
126
|
+
return [] unless component.respond_to?(:effects)
|
|
127
|
+
|
|
128
|
+
state = schema.build_data(state_hash)
|
|
129
|
+
Array(component.effects(state, msg))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def normalize_effects(effects)
|
|
133
|
+
Array(effects).compact
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def resolve_effect_msg(effect, controller, env)
|
|
137
|
+
return effect if effect.is_a?(Msg)
|
|
138
|
+
return effect.call(controller: controller, envelope: env) if effect.respond_to?(:call)
|
|
139
|
+
|
|
140
|
+
raise ArgumentError, 'Effect must respond to #call or be a Msg'
|
|
141
|
+
end
|
|
142
|
+
|
|
98
143
|
def deep_dup(obj)
|
|
99
144
|
Marshal.load(Marshal.dump(obj))
|
|
100
145
|
end
|
|
@@ -28,20 +28,12 @@ module ViewComponentReducible
|
|
|
28
28
|
class Builder
|
|
29
29
|
def initialize(schema) = (@schema = schema)
|
|
30
30
|
|
|
31
|
-
# Define a
|
|
31
|
+
# Define a state field.
|
|
32
32
|
# @param name [Symbol]
|
|
33
33
|
# @param default [Object, #call]
|
|
34
34
|
# @return [void]
|
|
35
35
|
def field(name, default:)
|
|
36
|
-
@schema.add_field(name, default
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# Define a meta field.
|
|
40
|
-
# @param name [Symbol]
|
|
41
|
-
# @param default [Object, #call]
|
|
42
|
-
# @return [void]
|
|
43
|
-
def meta(name, default:)
|
|
44
|
-
@schema.add_field(name, default:, kind: :meta)
|
|
36
|
+
@schema.add_field(name, default:)
|
|
45
37
|
end
|
|
46
38
|
end
|
|
47
39
|
end
|
|
@@ -8,16 +8,15 @@ module ViewComponentReducible
|
|
|
8
8
|
# @param root_component_klass [Class]
|
|
9
9
|
# @param path [String]
|
|
10
10
|
# @return [Hash{String=>Object}]
|
|
11
|
-
def self.initial(root_component_klass, path:
|
|
11
|
+
def self.initial(root_component_klass, path: 'root')
|
|
12
12
|
schema = root_component_klass.vcr_state_schema
|
|
13
|
-
data
|
|
13
|
+
data = schema.defaults
|
|
14
14
|
{
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"meta" => meta
|
|
15
|
+
'v' => 1,
|
|
16
|
+
'root' => root_component_klass.vcr_id,
|
|
17
|
+
'path' => path,
|
|
18
|
+
'data' => data,
|
|
19
|
+
'children' => {}
|
|
21
20
|
}
|
|
22
21
|
end
|
|
23
22
|
end
|
|
@@ -4,30 +4,48 @@ module ViewComponentReducible
|
|
|
4
4
|
module State
|
|
5
5
|
# State schema for defining default fields and building state payloads.
|
|
6
6
|
class Schema
|
|
7
|
-
Field = Struct.new(:name, :default,
|
|
7
|
+
Field = Struct.new(:name, :default, keyword_init: true)
|
|
8
8
|
|
|
9
9
|
def initialize
|
|
10
10
|
@fields = []
|
|
11
|
+
@data_class = nil
|
|
12
|
+
@data_class_fields = nil
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
# Add a field definition.
|
|
14
16
|
# @param name [Symbol]
|
|
15
17
|
# @param default [Object, #call]
|
|
16
|
-
# @param kind [Symbol] :data or :meta
|
|
17
18
|
# @return [void]
|
|
18
|
-
def add_field(name, default
|
|
19
|
-
@fields << Field.new(name:, default
|
|
19
|
+
def add_field(name, default:)
|
|
20
|
+
@fields << Field.new(name:, default:)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Class]
|
|
24
|
+
def data_class
|
|
25
|
+
field_names = @fields.map(&:name)
|
|
26
|
+
return @data_class if @data_class && @data_class_fields == field_names
|
|
27
|
+
|
|
28
|
+
defaults_proc = -> { build({}) }
|
|
29
|
+
@data_class_fields = field_names
|
|
30
|
+
@data_class = Data.define(*field_names) do
|
|
31
|
+
def [](key)
|
|
32
|
+
to_h[key.to_sym]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
define_method(:with_defaults) do
|
|
36
|
+
defaults = defaults_proc.call.transform_keys(&:to_sym)
|
|
37
|
+
self.class.new(**defaults)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
20
40
|
end
|
|
21
41
|
|
|
22
42
|
# Build state hashes from input payloads.
|
|
23
|
-
# @param
|
|
24
|
-
# @
|
|
25
|
-
|
|
26
|
-
def build(data_hash, meta_hash)
|
|
43
|
+
# @param state_hash [Hash]
|
|
44
|
+
# @return [Hash{String=>Object}]
|
|
45
|
+
def build(state_hash)
|
|
27
46
|
data = {}
|
|
28
|
-
meta = {}
|
|
29
47
|
@fields.each do |field|
|
|
30
|
-
src =
|
|
48
|
+
src = state_hash || {}
|
|
31
49
|
value = if src.key?(field.name.to_s)
|
|
32
50
|
src[field.name.to_s]
|
|
33
51
|
elsif src.key?(field.name)
|
|
@@ -36,19 +54,23 @@ module ViewComponentReducible
|
|
|
36
54
|
value = field.default.call if value.nil? && field.default.respond_to?(:call)
|
|
37
55
|
value = field.default if value.nil? && !field.default.respond_to?(:call)
|
|
38
56
|
|
|
39
|
-
|
|
40
|
-
meta[field.name.to_s] = value
|
|
41
|
-
else
|
|
42
|
-
data[field.name.to_s] = value
|
|
43
|
-
end
|
|
57
|
+
data[field.name.to_s] = value
|
|
44
58
|
end
|
|
45
|
-
|
|
59
|
+
data
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Build a Data object from input payloads.
|
|
63
|
+
# @param state_hash [Hash]
|
|
64
|
+
# @return [Data]
|
|
65
|
+
def build_data(state_hash)
|
|
66
|
+
data = build(state_hash)
|
|
67
|
+
data_class.new(**data.transform_keys(&:to_sym))
|
|
46
68
|
end
|
|
47
69
|
|
|
48
70
|
# Build default state hashes.
|
|
49
|
-
# @return [
|
|
71
|
+
# @return [Hash{String=>Object}]
|
|
50
72
|
def defaults
|
|
51
|
-
build({}
|
|
73
|
+
build({})
|
|
52
74
|
end
|
|
53
75
|
end
|
|
54
76
|
end
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative
|
|
4
|
-
require_relative
|
|
5
|
-
require_relative
|
|
6
|
-
require_relative
|
|
7
|
-
require_relative
|
|
8
|
-
require_relative
|
|
9
|
-
require_relative
|
|
10
|
-
require_relative
|
|
11
|
-
require_relative
|
|
12
|
-
require_relative
|
|
13
|
-
require_relative
|
|
14
|
-
require_relative
|
|
15
|
-
require_relative
|
|
16
|
-
require_relative
|
|
17
|
-
require_relative
|
|
18
|
-
require_relative
|
|
3
|
+
require_relative 'view_component_reducible/version'
|
|
4
|
+
require_relative 'view_component_reducible/configuration'
|
|
5
|
+
require_relative 'view_component_reducible/msg'
|
|
6
|
+
require_relative 'view_component_reducible/adapter/base'
|
|
7
|
+
require_relative 'view_component_reducible/adapter/hidden_field'
|
|
8
|
+
require_relative 'view_component_reducible/adapter/session'
|
|
9
|
+
require_relative 'view_component_reducible/helpers'
|
|
10
|
+
require_relative 'view_component_reducible/state/schema'
|
|
11
|
+
require_relative 'view_component_reducible/state/dsl'
|
|
12
|
+
require_relative 'view_component_reducible/state/envelope'
|
|
13
|
+
require_relative 'view_component_reducible/component'
|
|
14
|
+
require_relative 'view_component_reducible/runtime'
|
|
15
|
+
require_relative 'view_component_reducible/dispatch'
|
|
16
|
+
require_relative 'view_component_reducible/dispatch_controller'
|
|
17
|
+
require_relative 'view_component_reducible/engine'
|
|
18
|
+
require_relative 'view_component_reducible/railtie'
|
|
19
19
|
|
|
20
20
|
module ViewComponentReducible
|
|
21
21
|
class Error < StandardError; end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: view_component_reducible
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- manabeai
|
|
@@ -37,6 +37,20 @@ dependencies:
|
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '6.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: nokogiri
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.14'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.14'
|
|
40
54
|
- !ruby/object:Gem::Dependency
|
|
41
55
|
name: railties
|
|
42
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -108,7 +122,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
108
122
|
requirements:
|
|
109
123
|
- - ">="
|
|
110
124
|
- !ruby/object:Gem::Version
|
|
111
|
-
version: 3.
|
|
125
|
+
version: 3.2.0
|
|
112
126
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
113
127
|
requirements:
|
|
114
128
|
- - ">="
|