view_component_reducible 0.1.0 → 0.1.2
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 +11 -2
- data/lib/view_component_reducible/adapter/base.rb +28 -0
- data/lib/view_component_reducible/adapter/hidden_field.rb +31 -0
- data/lib/view_component_reducible/adapter/session.rb +35 -0
- data/lib/view_component_reducible/component.rb +102 -0
- data/lib/view_component_reducible/configuration.rb +33 -0
- data/lib/view_component_reducible/dispatch.rb +30 -0
- data/lib/view_component_reducible/dispatch_controller.rb +46 -0
- data/lib/view_component_reducible/engine.rb +10 -0
- data/lib/view_component_reducible/helpers.rb +95 -0
- data/lib/view_component_reducible/msg.rb +36 -0
- data/lib/view_component_reducible/railtie.rb +14 -0
- data/lib/view_component_reducible/runtime.rb +117 -0
- data/lib/view_component_reducible/state/dsl.rb +41 -0
- data/lib/view_component_reducible/state/envelope.rb +24 -0
- data/lib/view_component_reducible/state/schema.rb +77 -0
- data/lib/view_component_reducible/version.rb +1 -1
- data/lib/view_component_reducible.rb +38 -2
- metadata +89 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a699ce3503948db085a77d5d187e725157966bf53a132e9d9124a772db648f1c
|
|
4
|
+
data.tar.gz: ba68826392cd3ea83ca6ceea8f8f753d893f013467d61eff20f3085fd40c9e0c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9eca692a8a5b7185869237597cb79d199d4e1e036501f2c9c2574e40ea643e99535202f31fb44198a51e293e2fd5078adefd2e65756bd95fc8732917a688b892
|
|
7
|
+
data.tar.gz: bfe0b991478a5a8788f2b2f9c69f7705d5ad9bf1de828d76d50c5b652a8d57bb23c18dfe883289bf231c54b77885e311c35485e9d4a7fc37f200b87d113033a8
|
data/README.md
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
# view_component_reducible
|
|
2
2
|
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
This sample shows state transitions and partial updates powered entirely by ViewComponent.
|
|
6
|
+
The only thing you add in Rails is a single endpoint:
|
|
7
|
+
|
|
8
|
+
```rb
|
|
9
|
+
mount ViewComponentReducible::Engine, at: "/vcr"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Everything else is **reducible** to ViewComponent—no extra endpoints, controllers, WebSockets, or JS frameworks required.
|
|
13
|
+
|
|
3
14
|
view_component_reducible brings reducer-based state transitions
|
|
4
15
|
to Rails ViewComponent, inspired by TEA (The Elm Architecture).
|
|
5
|
-
|
|
6
|
-
This is a server-driven, HTTP-based approach — no WebSocket required.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponentReducible
|
|
4
|
+
module Adapter
|
|
5
|
+
# Base adapter interface for envelope serialization.
|
|
6
|
+
class Base
|
|
7
|
+
# @param secret [String]
|
|
8
|
+
def initialize(secret:)
|
|
9
|
+
@secret = secret
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Encode envelope to a client-safe token.
|
|
13
|
+
# @param envelope [Hash]
|
|
14
|
+
# @param request [ActionDispatch::Request]
|
|
15
|
+
# @return [String]
|
|
16
|
+
def dump(envelope, request:)
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Load envelope from a request.
|
|
21
|
+
# @param request [ActionDispatch::Request]
|
|
22
|
+
# @return [Hash]
|
|
23
|
+
def load(request:)
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support'
|
|
4
|
+
require 'active_support/message_verifier'
|
|
5
|
+
|
|
6
|
+
module ViewComponentReducible
|
|
7
|
+
module Adapter
|
|
8
|
+
# Hidden field adapter using signed payloads.
|
|
9
|
+
class HiddenField < Base
|
|
10
|
+
# @return [ActiveSupport::MessageVerifier]
|
|
11
|
+
def verifier
|
|
12
|
+
@verifier ||= ActiveSupport::MessageVerifier.new(@secret, digest: 'SHA256', serializer: JSON)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param envelope [Hash]
|
|
16
|
+
# @param request [ActionDispatch::Request]
|
|
17
|
+
# @return [String]
|
|
18
|
+
def dump(envelope, request: nil)
|
|
19
|
+
_ = request
|
|
20
|
+
verifier.generate(envelope)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param request [ActionDispatch::Request]
|
|
24
|
+
# @return [Hash]
|
|
25
|
+
def load(request:)
|
|
26
|
+
signed = request.params.fetch('vcr_state')
|
|
27
|
+
verifier.verify(signed)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support'
|
|
4
|
+
require 'active_support/message_verifier'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
|
|
7
|
+
module ViewComponentReducible
|
|
8
|
+
module Adapter
|
|
9
|
+
# Session adapter storing envelope in server session.
|
|
10
|
+
class Session < Base
|
|
11
|
+
# @return [ActiveSupport::MessageVerifier]
|
|
12
|
+
def verifier
|
|
13
|
+
@verifier ||= ActiveSupport::MessageVerifier.new(@secret, digest: 'SHA256', serializer: JSON)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @param envelope [Hash]
|
|
17
|
+
# @param request [ActionDispatch::Request]
|
|
18
|
+
# @return [String]
|
|
19
|
+
def dump(envelope, request:)
|
|
20
|
+
key = SecureRandom.hex(16)
|
|
21
|
+
request.session["vcr:#{key}"] = envelope
|
|
22
|
+
verifier.generate({ 'k' => key })
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param request [ActionDispatch::Request]
|
|
26
|
+
# @return [Hash]
|
|
27
|
+
def load(request:)
|
|
28
|
+
signed = request.params.fetch('vcr_state')
|
|
29
|
+
payload = verifier.verify(signed)
|
|
30
|
+
key = payload.fetch('k')
|
|
31
|
+
request.session.fetch("vcr:#{key}")
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nokogiri'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module ViewComponentReducible
|
|
7
|
+
# Include to enable the component state DSL and helpers.
|
|
8
|
+
module Component
|
|
9
|
+
# Hook to include DSL and class methods.
|
|
10
|
+
# @param base [Class]
|
|
11
|
+
# @return [void]
|
|
12
|
+
def self.included(base)
|
|
13
|
+
base.include(State::DSL)
|
|
14
|
+
base.extend(ClassMethods)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [Hash, nil]
|
|
18
|
+
attr_reader :vcr_envelope
|
|
19
|
+
# @return [String, nil]
|
|
20
|
+
attr_reader :vcr_state_token
|
|
21
|
+
|
|
22
|
+
# @param vcr_envelope [Hash, nil]
|
|
23
|
+
# @param vcr_state_token [String, nil]
|
|
24
|
+
def initialize(vcr_envelope: nil, vcr_state_token: nil, **kwargs)
|
|
25
|
+
@vcr_envelope = vcr_envelope
|
|
26
|
+
@vcr_state_token = vcr_state_token
|
|
27
|
+
return unless defined?(super)
|
|
28
|
+
|
|
29
|
+
kwargs.empty? ? super() : super(**kwargs)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Build state hash for rendering from the envelope.
|
|
33
|
+
# @return [Hash{String=>Object}]
|
|
34
|
+
def vcr_state
|
|
35
|
+
return {} if vcr_envelope.nil?
|
|
36
|
+
|
|
37
|
+
schema = self.class.vcr_state_schema
|
|
38
|
+
schema.build(vcr_envelope['data'])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Optional DOM target id for updates.
|
|
42
|
+
# @param path [String]
|
|
43
|
+
# @return [String]
|
|
44
|
+
def vcr_dom_id(path:)
|
|
45
|
+
"vcr:#{self.class.vcr_id}:#{path}"
|
|
46
|
+
end
|
|
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
|
+
|
|
66
|
+
module ClassMethods
|
|
67
|
+
# Stable component identifier for envelopes.
|
|
68
|
+
# @return [String]
|
|
69
|
+
def vcr_id
|
|
70
|
+
name.to_s
|
|
71
|
+
end
|
|
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
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponentReducible
|
|
4
|
+
# Global configuration for adapter and secrets.
|
|
5
|
+
class Configuration
|
|
6
|
+
# @return [Class]
|
|
7
|
+
attr_accessor :adapter, :secret
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@adapter = Adapter::Session
|
|
11
|
+
@secret = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Build adapter instance for a controller request.
|
|
15
|
+
# @param controller [ActionController::Base]
|
|
16
|
+
# @param adapter_class [Class, nil]
|
|
17
|
+
# @return [ViewComponentReducible::Adapter::Base]
|
|
18
|
+
def adapter_for(_controller, adapter_class: nil)
|
|
19
|
+
resolved_secret = secret || default_secret
|
|
20
|
+
raise 'ViewComponentReducible secret is missing' if resolved_secret.nil?
|
|
21
|
+
|
|
22
|
+
(adapter_class || adapter).new(secret: resolved_secret)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def default_secret
|
|
28
|
+
return Rails.application.secret_key_base if defined?(Rails) && Rails.respond_to?(:application)
|
|
29
|
+
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
|
|
5
|
+
module ViewComponentReducible
|
|
6
|
+
# Helpers for dispatch responses.
|
|
7
|
+
module Dispatch
|
|
8
|
+
# Inject a signed state token into HTML.
|
|
9
|
+
# @param html [String]
|
|
10
|
+
# @param signed_state [String]
|
|
11
|
+
# @return [String]
|
|
12
|
+
def self.inject_state(html, signed_state)
|
|
13
|
+
meta = %(<meta name="vcr-state" content="#{ERB::Util.html_escape(signed_state)}">)
|
|
14
|
+
script = <<~SCRIPT.chomp
|
|
15
|
+
<script>
|
|
16
|
+
(function() {
|
|
17
|
+
var meta = document.querySelector('meta[name="vcr-state"]');
|
|
18
|
+
if (!meta) return;
|
|
19
|
+
var state = meta.getAttribute('content');
|
|
20
|
+
var inputs = document.querySelectorAll('input[name="vcr_state"]');
|
|
21
|
+
inputs.forEach(function(input) { input.value = state; });
|
|
22
|
+
})();
|
|
23
|
+
</script>
|
|
24
|
+
SCRIPT
|
|
25
|
+
|
|
26
|
+
injection = meta + script
|
|
27
|
+
html.include?('</head>') ? html.sub('</head>', "#{injection}</head>") : (injection + html)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'action_controller'
|
|
4
|
+
|
|
5
|
+
module ViewComponentReducible
|
|
6
|
+
# Rails controller entry for dispatch requests.
|
|
7
|
+
class DispatchController < ActionController::Base
|
|
8
|
+
protect_from_forgery with: :exception
|
|
9
|
+
|
|
10
|
+
# @param adapter_class [Class]
|
|
11
|
+
# @return [Class, nil]
|
|
12
|
+
def self.vcr_adapter(adapter_class = nil)
|
|
13
|
+
return @vcr_adapter if adapter_class.nil?
|
|
14
|
+
|
|
15
|
+
@vcr_adapter = adapter_class
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [void]
|
|
19
|
+
def call
|
|
20
|
+
adapter_class = self.class.vcr_adapter || ViewComponentReducible.config.adapter
|
|
21
|
+
adapter = ViewComponentReducible.config.adapter_for(self, adapter_class: adapter_class)
|
|
22
|
+
envelope = adapter.load(request:)
|
|
23
|
+
msg = ViewComponentReducible::Msg.from_params(params)
|
|
24
|
+
target_path = params.fetch('vcr_target_path', envelope['path'])
|
|
25
|
+
|
|
26
|
+
runtime = ViewComponentReducible::Runtime.new
|
|
27
|
+
new_envelope, html = runtime.call(
|
|
28
|
+
envelope:,
|
|
29
|
+
msg:,
|
|
30
|
+
target_path:,
|
|
31
|
+
controller: self
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
signed = adapter.dump(new_envelope, request:)
|
|
35
|
+
if params['vcr_partial'] == '1'
|
|
36
|
+
partial_html = runtime.render_target(envelope: new_envelope, target_path:, controller: self)
|
|
37
|
+
response.set_header('X-VCR-State', signed)
|
|
38
|
+
render html: partial_html, content_type: 'text/html'
|
|
39
|
+
else
|
|
40
|
+
render html: ViewComponentReducible::Dispatch.inject_state(html, signed), content_type: 'text/html'
|
|
41
|
+
end
|
|
42
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
43
|
+
render status: 400, plain: 'Invalid state signature'
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module ViewComponentReducible
|
|
6
|
+
# View helpers for dispatching messages to the VCR endpoint.
|
|
7
|
+
module Helpers
|
|
8
|
+
# Wrap component markup with a boundary for partial updates.
|
|
9
|
+
# @param path [String]
|
|
10
|
+
# @yield block for component content
|
|
11
|
+
# @return [String]
|
|
12
|
+
def vcr_boundary(path:, &block)
|
|
13
|
+
content_tag(:div, capture(&block), data: { vcr_path: path })
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Build a dispatch form with hidden fields for the VCR endpoint.
|
|
17
|
+
# @param state [String] signed state token
|
|
18
|
+
# @param msg_type [String]
|
|
19
|
+
# @param msg_payload [Hash, String]
|
|
20
|
+
# @param target_path [String, nil]
|
|
21
|
+
# @param url [String]
|
|
22
|
+
# @yield block for the form body (e.g., submit button)
|
|
23
|
+
# @return [String]
|
|
24
|
+
def vcr_dispatch_form(state:, msg_type:, msg_payload: {}, target_path: nil, url: '/vcr/dispatch', &block)
|
|
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'
|
|
27
|
+
|
|
28
|
+
form_tag(url, method: :post, data: { vcr_form: true }) do
|
|
29
|
+
body = [
|
|
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) : '')
|
|
35
|
+
]
|
|
36
|
+
safe_join(body)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Insert the minimal JS dispatcher for partial updates.
|
|
41
|
+
# @return [String]
|
|
42
|
+
def vcr_dispatch_script_tag
|
|
43
|
+
js = <<~JS
|
|
44
|
+
(function() {
|
|
45
|
+
if (window.__vcrDispatchInstalled) return;
|
|
46
|
+
window.__vcrDispatchInstalled = true;
|
|
47
|
+
document.addEventListener("submit", function(event) {
|
|
48
|
+
var form = event.target;
|
|
49
|
+
if (!(form instanceof HTMLFormElement)) return;
|
|
50
|
+
if (!form.matches("[data-vcr-form]")) return;
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
var formData = new FormData(form);
|
|
53
|
+
formData.append("vcr_partial", "1");
|
|
54
|
+
fetch(form.action, {
|
|
55
|
+
method: (form.method || "POST").toUpperCase(),
|
|
56
|
+
body: formData,
|
|
57
|
+
headers: { "X-Requested-With": "XMLHttpRequest" }
|
|
58
|
+
})
|
|
59
|
+
.then(function(response) {
|
|
60
|
+
var state = response.headers.get("X-VCR-State");
|
|
61
|
+
return response.text().then(function(html) {
|
|
62
|
+
return { html: html, state: state };
|
|
63
|
+
});
|
|
64
|
+
})
|
|
65
|
+
.then(function(payload) {
|
|
66
|
+
var targetPath = formData.get("vcr_target_path");
|
|
67
|
+
var parser = new DOMParser();
|
|
68
|
+
var doc = parser.parseFromString(payload.html, "text/html");
|
|
69
|
+
var newNode = doc.querySelector('[data-vcr-path="' + targetPath + '"]') || doc.body.firstElementChild;
|
|
70
|
+
var current = document.querySelector('[data-vcr-path="' + targetPath + '"]');
|
|
71
|
+
if (newNode && current) {
|
|
72
|
+
current.replaceWith(newNode);
|
|
73
|
+
}
|
|
74
|
+
if (payload.state) {
|
|
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
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
})();
|
|
85
|
+
JS
|
|
86
|
+
content_tag(:script, js.html_safe)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def vcr_envelope_path
|
|
90
|
+
return unless respond_to?(:vcr_envelope) && vcr_envelope
|
|
91
|
+
|
|
92
|
+
vcr_envelope['path']
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module ViewComponentReducible
|
|
6
|
+
# Message payload sent from the client.
|
|
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
|
+
|
|
16
|
+
# Build a Msg from request params.
|
|
17
|
+
# @param params [Hash]
|
|
18
|
+
# @return [ViewComponentReducible::Msg]
|
|
19
|
+
def self.from_params(params)
|
|
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
|
+
new(type:, payload:)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def normalized_type
|
|
29
|
+
type.to_s
|
|
30
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
31
|
+
.tr('-', '_')
|
|
32
|
+
.downcase
|
|
33
|
+
.to_sym
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/railtie'
|
|
4
|
+
|
|
5
|
+
module ViewComponentReducible
|
|
6
|
+
# Railtie for wiring helpers into ActionView.
|
|
7
|
+
class Railtie < ::Rails::Railtie
|
|
8
|
+
initializer 'view_component_reducible.helpers' do
|
|
9
|
+
ActiveSupport.on_load(:action_view) do
|
|
10
|
+
include ViewComponentReducible::Helpers
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponentReducible
|
|
4
|
+
# Core runtime for dispatching messages and rendering components.
|
|
5
|
+
class Runtime
|
|
6
|
+
MAX_EFFECT_STEPS = 8
|
|
7
|
+
|
|
8
|
+
# @param envelope [Hash]
|
|
9
|
+
# @param msg [ViewComponentReducible::Msg]
|
|
10
|
+
# @param target_path [String]
|
|
11
|
+
# @param controller [ActionController::Base]
|
|
12
|
+
# @return [Array<Hash, String>] [new_envelope, html]
|
|
13
|
+
def call(envelope:, msg:, target_path:, controller:)
|
|
14
|
+
root_klass = ViewComponentReducible.registry.fetch(envelope['root'])
|
|
15
|
+
new_env = deep_dup(envelope)
|
|
16
|
+
|
|
17
|
+
new_env = dispatch_to_path(root_klass, new_env, msg, target_path, controller)
|
|
18
|
+
html = render_root(root_klass, new_env, controller)
|
|
19
|
+
[new_env, html]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Render HTML for a specific path after state updates.
|
|
23
|
+
# @param envelope [Hash]
|
|
24
|
+
# @param target_path [String]
|
|
25
|
+
# @param controller [ActionController::Base]
|
|
26
|
+
# @return [String]
|
|
27
|
+
def render_target(envelope:, target_path:, controller:)
|
|
28
|
+
root_klass = ViewComponentReducible.registry.fetch(envelope['root'])
|
|
29
|
+
component_klass, env = find_env_and_class(root_klass, envelope, target_path)
|
|
30
|
+
controller.view_context.render(component_klass.new(vcr_envelope: env))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def dispatch_to_path(root_klass, env, msg, target_path, controller)
|
|
36
|
+
if target_path == env['path']
|
|
37
|
+
apply_reducer(root_klass, env, msg, controller)
|
|
38
|
+
else
|
|
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
|
+
env
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def apply_reducer(component_klass, env, msg, controller)
|
|
47
|
+
component = component_klass.new(vcr_envelope: env)
|
|
48
|
+
schema = component_klass.vcr_state_schema
|
|
49
|
+
state = schema.build_data(env['data'])
|
|
50
|
+
|
|
51
|
+
new_state = component.reduce(state, msg)
|
|
52
|
+
env['data'] = normalize_state(new_state, schema)
|
|
53
|
+
effects = build_effects(component, schema, env['data'], msg)
|
|
54
|
+
|
|
55
|
+
run_effects(component_klass, env, effects, controller)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def run_effects(component_klass, env, effects, controller)
|
|
59
|
+
return env if effects.nil? || effects.empty?
|
|
60
|
+
|
|
61
|
+
effects_queue = effects.dup
|
|
62
|
+
steps = 0
|
|
63
|
+
|
|
64
|
+
while (eff = effects_queue.shift)
|
|
65
|
+
steps += 1
|
|
66
|
+
raise 'Too many effect steps' if steps > MAX_EFFECT_STEPS
|
|
67
|
+
|
|
68
|
+
follow_msg = eff.call(controller: controller, envelope: env)
|
|
69
|
+
next unless follow_msg
|
|
70
|
+
|
|
71
|
+
component = component_klass.new(vcr_envelope: env)
|
|
72
|
+
schema = component_klass.vcr_state_schema
|
|
73
|
+
state = schema.build_data(env['data'])
|
|
74
|
+
|
|
75
|
+
new_state = component.reduce(state, follow_msg)
|
|
76
|
+
env['data'] = normalize_state(new_state, schema)
|
|
77
|
+
new_effects = build_effects(component, schema, env['data'], follow_msg)
|
|
78
|
+
effects_queue.concat(Array(new_effects))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
env
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def render_root(root_klass, env, controller)
|
|
85
|
+
controller.view_context.render(root_klass.new(vcr_envelope: env))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def find_env_and_class(component_klass, env, target_path)
|
|
89
|
+
return [component_klass, env] if target_path == env['path']
|
|
90
|
+
|
|
91
|
+
child = env['children'].fetch(target_path) { raise KeyError, "Unknown path: #{target_path}" }
|
|
92
|
+
child_klass = ViewComponentReducible.registry.fetch(child['root'])
|
|
93
|
+
find_env_and_class(child_klass, child, target_path)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def normalize_state(state, schema)
|
|
97
|
+
if state.is_a?(schema.data_class)
|
|
98
|
+
state.to_h.transform_keys(&:to_s)
|
|
99
|
+
elsif state.is_a?(Hash)
|
|
100
|
+
schema.build(state)
|
|
101
|
+
else
|
|
102
|
+
raise ArgumentError, "Reducer must return a Hash or #{schema.data_class}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def build_effects(component, schema, state_hash, msg)
|
|
107
|
+
return [] unless component.respond_to?(:effects)
|
|
108
|
+
|
|
109
|
+
state = schema.build_data(state_hash)
|
|
110
|
+
Array(component.effects(state, msg))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def deep_dup(obj)
|
|
114
|
+
Marshal.load(Marshal.dump(obj))
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponentReducible
|
|
4
|
+
module State
|
|
5
|
+
# DSL for defining state schemas on components.
|
|
6
|
+
module DSL
|
|
7
|
+
# Hook to extend class methods when included.
|
|
8
|
+
# @param base [Class]
|
|
9
|
+
# @return [void]
|
|
10
|
+
def self.included(base)
|
|
11
|
+
base.extend(ClassMethods)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module ClassMethods
|
|
15
|
+
# Define a state schema for the component.
|
|
16
|
+
# @yield DSL block to declare fields.
|
|
17
|
+
# @return [void]
|
|
18
|
+
def state(&block)
|
|
19
|
+
schema = Schema.new
|
|
20
|
+
dsl = Builder.new(schema)
|
|
21
|
+
dsl.instance_eval(&block)
|
|
22
|
+
@vcr_state_schema = schema
|
|
23
|
+
|
|
24
|
+
define_singleton_method(:vcr_state_schema) { @vcr_state_schema }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class Builder
|
|
29
|
+
def initialize(schema) = (@schema = schema)
|
|
30
|
+
|
|
31
|
+
# Define a state field.
|
|
32
|
+
# @param name [Symbol]
|
|
33
|
+
# @param default [Object, #call]
|
|
34
|
+
# @return [void]
|
|
35
|
+
def field(name, default:)
|
|
36
|
+
@schema.add_field(name, default:)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponentReducible
|
|
4
|
+
module State
|
|
5
|
+
# Envelope builder for initial state payloads.
|
|
6
|
+
class Envelope
|
|
7
|
+
# Build an initial envelope for a component.
|
|
8
|
+
# @param root_component_klass [Class]
|
|
9
|
+
# @param path [String]
|
|
10
|
+
# @return [Hash{String=>Object}]
|
|
11
|
+
def self.initial(root_component_klass, path: 'root')
|
|
12
|
+
schema = root_component_klass.vcr_state_schema
|
|
13
|
+
data = schema.defaults
|
|
14
|
+
{
|
|
15
|
+
'v' => 1,
|
|
16
|
+
'root' => root_component_klass.vcr_id,
|
|
17
|
+
'path' => path,
|
|
18
|
+
'data' => data,
|
|
19
|
+
'children' => {}
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponentReducible
|
|
4
|
+
module State
|
|
5
|
+
# State schema for defining default fields and building state payloads.
|
|
6
|
+
class Schema
|
|
7
|
+
Field = Struct.new(:name, :default, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@fields = []
|
|
11
|
+
@data_class = nil
|
|
12
|
+
@data_class_fields = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Add a field definition.
|
|
16
|
+
# @param name [Symbol]
|
|
17
|
+
# @param default [Object, #call]
|
|
18
|
+
# @return [void]
|
|
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
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Build state hashes from input payloads.
|
|
43
|
+
# @param state_hash [Hash]
|
|
44
|
+
# @return [Hash{String=>Object}]
|
|
45
|
+
def build(state_hash)
|
|
46
|
+
data = {}
|
|
47
|
+
@fields.each do |field|
|
|
48
|
+
src = state_hash || {}
|
|
49
|
+
value = if src.key?(field.name.to_s)
|
|
50
|
+
src[field.name.to_s]
|
|
51
|
+
elsif src.key?(field.name)
|
|
52
|
+
src[field.name]
|
|
53
|
+
end
|
|
54
|
+
value = field.default.call if value.nil? && field.default.respond_to?(:call)
|
|
55
|
+
value = field.default if value.nil? && !field.default.respond_to?(:call)
|
|
56
|
+
|
|
57
|
+
data[field.name.to_s] = value
|
|
58
|
+
end
|
|
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))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Build default state hashes.
|
|
71
|
+
# @return [Hash{String=>Object}]
|
|
72
|
+
def defaults
|
|
73
|
+
build({})
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -1,8 +1,44 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
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'
|
|
4
19
|
|
|
5
20
|
module ViewComponentReducible
|
|
6
21
|
class Error < StandardError; end
|
|
7
|
-
|
|
22
|
+
|
|
23
|
+
# @return [Hash{String=>Class}]
|
|
24
|
+
def self.registry
|
|
25
|
+
@registry ||= {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param component_klass [Class]
|
|
29
|
+
# @return [void]
|
|
30
|
+
def self.register(component_klass)
|
|
31
|
+
registry[component_klass.vcr_id] = component_klass
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [ViewComponentReducible::Configuration]
|
|
35
|
+
def self.config
|
|
36
|
+
@config ||= Configuration.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @yield [ViewComponentReducible::Configuration]
|
|
40
|
+
# @return [void]
|
|
41
|
+
def self.configure
|
|
42
|
+
yield config
|
|
43
|
+
end
|
|
8
44
|
end
|
metadata
CHANGED
|
@@ -1,15 +1,86 @@
|
|
|
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.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- manabeai
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
-
dependencies:
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: actionpack
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '6.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '6.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activesupport
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '6.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
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'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: railties
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '6.1'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '6.1'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: view_component
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '2.0'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '2.0'
|
|
12
82
|
description: |
|
|
83
|
+
This gem is intentionally published early. The API is unstable, but the idea is stable.
|
|
13
84
|
view_component_reducible brings reducer-style (TEA-inspired) state transitions
|
|
14
85
|
to Rails ViewComponent. Server-driven, HTTP-based, no WebSocket required.
|
|
15
86
|
email:
|
|
@@ -21,6 +92,21 @@ files:
|
|
|
21
92
|
- LICENSE.txt
|
|
22
93
|
- README.md
|
|
23
94
|
- lib/view_component_reducible.rb
|
|
95
|
+
- lib/view_component_reducible/adapter/base.rb
|
|
96
|
+
- lib/view_component_reducible/adapter/hidden_field.rb
|
|
97
|
+
- lib/view_component_reducible/adapter/session.rb
|
|
98
|
+
- lib/view_component_reducible/component.rb
|
|
99
|
+
- lib/view_component_reducible/configuration.rb
|
|
100
|
+
- lib/view_component_reducible/dispatch.rb
|
|
101
|
+
- lib/view_component_reducible/dispatch_controller.rb
|
|
102
|
+
- lib/view_component_reducible/engine.rb
|
|
103
|
+
- lib/view_component_reducible/helpers.rb
|
|
104
|
+
- lib/view_component_reducible/msg.rb
|
|
105
|
+
- lib/view_component_reducible/railtie.rb
|
|
106
|
+
- lib/view_component_reducible/runtime.rb
|
|
107
|
+
- lib/view_component_reducible/state/dsl.rb
|
|
108
|
+
- lib/view_component_reducible/state/envelope.rb
|
|
109
|
+
- lib/view_component_reducible/state/schema.rb
|
|
24
110
|
- lib/view_component_reducible/version.rb
|
|
25
111
|
homepage: https://github.com/manabeai/view_component_reducible
|
|
26
112
|
licenses:
|
|
@@ -36,7 +122,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
36
122
|
requirements:
|
|
37
123
|
- - ">="
|
|
38
124
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: 3.
|
|
125
|
+
version: 3.2.0
|
|
40
126
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
41
127
|
requirements:
|
|
42
128
|
- - ">="
|