view_component_reducible 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +15 -0
- data/lib/view_component_reducible/adapter/base.rb +28 -0
- data/lib/view_component_reducible/adapter/hidden_field.rb +30 -0
- data/lib/view_component_reducible/adapter/session.rb +35 -0
- data/lib/view_component_reducible/component.rb +54 -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 +14 -0
- data/lib/view_component_reducible/helpers.rb +85 -0
- data/lib/view_component_reducible/msg.rb +18 -0
- data/lib/view_component_reducible/railtie.rb +14 -0
- data/lib/view_component_reducible/runtime.rb +102 -0
- data/lib/view_component_reducible/state/dsl.rb +49 -0
- data/lib/view_component_reducible/state/envelope.rb +25 -0
- data/lib/view_component_reducible/state/schema.rb +55 -0
- data/lib/view_component_reducible/version.rb +5 -0
- data/lib/view_component_reducible.rb +44 -0
- metadata +120 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b815772f1cd9c519f38613dc6629c94fb0951a2095d4434a092fdbae142b7564
|
|
4
|
+
data.tar.gz: 65a95eba94127d4373b35512feaa8587a622ed91a47ad425eec732922e4a5e4e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4ed5dbd74b6a77153e4226f4c141834879c0ae668d711eeec1b8486e10a9de283f413011ff3469977e410bb44d8b5e4758b2ff728a5d0cb56a41b422a29f5601
|
|
7
|
+
data.tar.gz: 66817291cf7433b3575772a9aa6fbf00b5449e8ddd470fb8fa95a00373e007a26d54d7b40c91bf159212f29bb9bcd7602fd8160d86b5a1537eff589e5b78ed9b
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 manabeai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# view_component_reducible
|
|
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 stays inside ViewComponent—no extra endpoints, controllers, WebSockets, or JS frameworks required.
|
|
13
|
+
|
|
14
|
+
view_component_reducible brings reducer-based state transitions
|
|
15
|
+
to Rails ViewComponent, inspired by TEA (The Elm Architecture).
|
|
@@ -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,30 @@
|
|
|
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:)
|
|
19
|
+
verifier.generate(envelope)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param request [ActionDispatch::Request]
|
|
23
|
+
# @return [Hash]
|
|
24
|
+
def load(request:)
|
|
25
|
+
signed = request.params.fetch("vcr_state")
|
|
26
|
+
verifier.verify(signed)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
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,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ViewComponentReducible
|
|
4
|
+
# Include to enable the component state DSL and helpers.
|
|
5
|
+
module Component
|
|
6
|
+
# Hook to include DSL and class methods.
|
|
7
|
+
# @param base [Class]
|
|
8
|
+
# @return [void]
|
|
9
|
+
def self.included(base)
|
|
10
|
+
base.include(State::DSL)
|
|
11
|
+
base.extend(ClassMethods)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [Hash, nil]
|
|
15
|
+
attr_reader :vcr_envelope
|
|
16
|
+
# @return [String, nil]
|
|
17
|
+
attr_reader :vcr_state_token
|
|
18
|
+
|
|
19
|
+
# @param vcr_envelope [Hash, nil]
|
|
20
|
+
# @param vcr_state_token [String, nil]
|
|
21
|
+
def initialize(vcr_envelope: nil, vcr_state_token: nil, **kwargs)
|
|
22
|
+
@vcr_envelope = vcr_envelope
|
|
23
|
+
@vcr_state_token = vcr_state_token
|
|
24
|
+
return unless defined?(super)
|
|
25
|
+
|
|
26
|
+
kwargs.empty? ? super() : super(**kwargs)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Build state hash for rendering from the envelope.
|
|
30
|
+
# @return [Hash{String=>Hash}]
|
|
31
|
+
def vcr_state
|
|
32
|
+
return { "data" => {}, "meta" => {} } if vcr_envelope.nil?
|
|
33
|
+
|
|
34
|
+
schema = self.class.vcr_state_schema
|
|
35
|
+
data, meta = schema.build(vcr_envelope["data"], vcr_envelope["meta"])
|
|
36
|
+
{ "data" => data, "meta" => meta }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Optional DOM target id for updates.
|
|
40
|
+
# @param path [String]
|
|
41
|
+
# @return [String]
|
|
42
|
+
def vcr_dom_id(path:)
|
|
43
|
+
"vcr:#{self.class.vcr_id}:#{path}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
module ClassMethods
|
|
47
|
+
# Stable component identifier for envelopes.
|
|
48
|
+
# @return [String]
|
|
49
|
+
def vcr_id
|
|
50
|
+
name.to_s
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
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,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module ViewComponentReducible
|
|
6
|
+
# Rails engine for mounting dispatch routes.
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace ViewComponentReducible
|
|
9
|
+
|
|
10
|
+
routes.draw do
|
|
11
|
+
post "/dispatch", to: "dispatch#call"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
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]
|
|
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: "root", url: "/vcr/dispatch", &block)
|
|
25
|
+
payload = msg_payload.is_a?(String) ? msg_payload : JSON.generate(msg_payload)
|
|
26
|
+
|
|
27
|
+
form_tag(url, method: :post, data: { vcr_form: true }) do
|
|
28
|
+
body = [
|
|
29
|
+
hidden_field_tag("vcr_state", state),
|
|
30
|
+
hidden_field_tag("vcr_msg_type", msg_type),
|
|
31
|
+
hidden_field_tag("vcr_msg_payload", payload),
|
|
32
|
+
hidden_field_tag("vcr_target_path", target_path),
|
|
33
|
+
(block_given? ? capture(&block) : "")
|
|
34
|
+
]
|
|
35
|
+
safe_join(body)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Insert the minimal JS dispatcher for partial updates.
|
|
40
|
+
# @return [String]
|
|
41
|
+
def vcr_dispatch_script_tag
|
|
42
|
+
js = <<~JS
|
|
43
|
+
(function() {
|
|
44
|
+
if (window.__vcrDispatchInstalled) return;
|
|
45
|
+
window.__vcrDispatchInstalled = true;
|
|
46
|
+
document.addEventListener("submit", function(event) {
|
|
47
|
+
var form = event.target;
|
|
48
|
+
if (!(form instanceof HTMLFormElement)) return;
|
|
49
|
+
if (!form.matches("[data-vcr-form]")) return;
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
var formData = new FormData(form);
|
|
52
|
+
formData.append("vcr_partial", "1");
|
|
53
|
+
fetch(form.action, {
|
|
54
|
+
method: (form.method || "POST").toUpperCase(),
|
|
55
|
+
body: formData,
|
|
56
|
+
headers: { "X-Requested-With": "XMLHttpRequest" }
|
|
57
|
+
})
|
|
58
|
+
.then(function(response) {
|
|
59
|
+
var state = response.headers.get("X-VCR-State");
|
|
60
|
+
return response.text().then(function(html) {
|
|
61
|
+
return { html: html, state: state };
|
|
62
|
+
});
|
|
63
|
+
})
|
|
64
|
+
.then(function(payload) {
|
|
65
|
+
var targetPath = formData.get("vcr_target_path");
|
|
66
|
+
var parser = new DOMParser();
|
|
67
|
+
var doc = parser.parseFromString(payload.html, "text/html");
|
|
68
|
+
var newNode = doc.querySelector('[data-vcr-path="' + targetPath + '"]') || doc.body.firstElementChild;
|
|
69
|
+
var current = document.querySelector('[data-vcr-path="' + targetPath + '"]');
|
|
70
|
+
if (newNode && current) {
|
|
71
|
+
current.replaceWith(newNode);
|
|
72
|
+
}
|
|
73
|
+
if (payload.state) {
|
|
74
|
+
document.querySelectorAll('input[name="vcr_state"]').forEach(function(input) {
|
|
75
|
+
input.value = payload.state;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
})();
|
|
81
|
+
JS
|
|
82
|
+
content_tag(:script, js.html_safe)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
# Build a Msg from request params.
|
|
9
|
+
# @param params [Hash]
|
|
10
|
+
# @return [ViewComponentReducible::Msg]
|
|
11
|
+
def self.from_params(params)
|
|
12
|
+
type = params.fetch("vcr_msg_type")
|
|
13
|
+
payload_json = params["vcr_msg_payload"]
|
|
14
|
+
payload = payload_json && payload_json != "" ? JSON.parse(payload_json) : {}
|
|
15
|
+
new(type:, payload:)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
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,102 @@
|
|
|
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
|
+
data, meta = schema.build(env["data"], env["meta"])
|
|
50
|
+
state = { "data" => data, "meta" => meta }
|
|
51
|
+
|
|
52
|
+
new_state, effects = component.reduce(state, msg)
|
|
53
|
+
env["data"] = new_state["data"]
|
|
54
|
+
env["meta"] = new_state["meta"]
|
|
55
|
+
|
|
56
|
+
run_effects(component_klass, env, effects, controller)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def run_effects(component_klass, env, effects, controller)
|
|
60
|
+
return env if effects.nil? || effects.empty?
|
|
61
|
+
|
|
62
|
+
effects_queue = effects.dup
|
|
63
|
+
steps = 0
|
|
64
|
+
|
|
65
|
+
while (eff = effects_queue.shift)
|
|
66
|
+
steps += 1
|
|
67
|
+
raise "Too many effect steps" if steps > MAX_EFFECT_STEPS
|
|
68
|
+
|
|
69
|
+
follow_msg = eff.call(controller: controller, envelope: env)
|
|
70
|
+
next unless follow_msg
|
|
71
|
+
|
|
72
|
+
component = component_klass.new(vcr_envelope: env)
|
|
73
|
+
schema = component_klass.vcr_state_schema
|
|
74
|
+
data, meta = schema.build(env["data"], env["meta"])
|
|
75
|
+
state = { "data" => data, "meta" => meta }
|
|
76
|
+
|
|
77
|
+
new_state, new_effects = component.reduce(state, follow_msg)
|
|
78
|
+
env["data"] = new_state["data"]
|
|
79
|
+
env["meta"] = new_state["meta"]
|
|
80
|
+
effects_queue.concat(Array(new_effects))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
env
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def render_root(root_klass, env, controller)
|
|
87
|
+
controller.view_context.render(root_klass.new(vcr_envelope: env))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def find_env_and_class(component_klass, env, target_path)
|
|
91
|
+
return [component_klass, env] if target_path == env["path"]
|
|
92
|
+
|
|
93
|
+
child = env["children"].fetch(target_path) { raise KeyError, "Unknown path: #{target_path}" }
|
|
94
|
+
child_klass = ViewComponentReducible.registry.fetch(child["root"])
|
|
95
|
+
find_env_and_class(child_klass, child, target_path)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def deep_dup(obj)
|
|
99
|
+
Marshal.load(Marshal.dump(obj))
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
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 data field.
|
|
32
|
+
# @param name [Symbol]
|
|
33
|
+
# @param default [Object, #call]
|
|
34
|
+
# @return [void]
|
|
35
|
+
def field(name, default:)
|
|
36
|
+
@schema.add_field(name, default:, kind: :data)
|
|
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)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
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, meta = schema.defaults
|
|
14
|
+
{
|
|
15
|
+
"v" => 1,
|
|
16
|
+
"root" => root_component_klass.vcr_id,
|
|
17
|
+
"path" => path,
|
|
18
|
+
"data" => data,
|
|
19
|
+
"children" => {},
|
|
20
|
+
"meta" => meta
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
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, :kind, keyword_init: true) # kind: :data or :meta
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@fields = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Add a field definition.
|
|
14
|
+
# @param name [Symbol]
|
|
15
|
+
# @param default [Object, #call]
|
|
16
|
+
# @param kind [Symbol] :data or :meta
|
|
17
|
+
# @return [void]
|
|
18
|
+
def add_field(name, default:, kind:)
|
|
19
|
+
@fields << Field.new(name:, default:, kind:)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Build state hashes from input payloads.
|
|
23
|
+
# @param data_hash [Hash]
|
|
24
|
+
# @param meta_hash [Hash]
|
|
25
|
+
# @return [Array<Hash{String=>Object}>] [data, meta]
|
|
26
|
+
def build(data_hash, meta_hash)
|
|
27
|
+
data = {}
|
|
28
|
+
meta = {}
|
|
29
|
+
@fields.each do |field|
|
|
30
|
+
src = (field.kind == :meta ? meta_hash : data_hash) || {}
|
|
31
|
+
value = if src.key?(field.name.to_s)
|
|
32
|
+
src[field.name.to_s]
|
|
33
|
+
elsif src.key?(field.name)
|
|
34
|
+
src[field.name]
|
|
35
|
+
end
|
|
36
|
+
value = field.default.call if value.nil? && field.default.respond_to?(:call)
|
|
37
|
+
value = field.default if value.nil? && !field.default.respond_to?(:call)
|
|
38
|
+
|
|
39
|
+
if field.kind == :meta
|
|
40
|
+
meta[field.name.to_s] = value
|
|
41
|
+
else
|
|
42
|
+
data[field.name.to_s] = value
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
[data, meta]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Build default state hashes.
|
|
49
|
+
# @return [Array<Hash{String=>Object}>] [data, meta]
|
|
50
|
+
def defaults
|
|
51
|
+
build({}, {})
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
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
|
+
|
|
20
|
+
module ViewComponentReducible
|
|
21
|
+
class Error < StandardError; end
|
|
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
|
|
44
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: view_component_reducible
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- manabeai
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
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: railties
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '6.1'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '6.1'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: view_component
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '2.0'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '2.0'
|
|
68
|
+
description: |
|
|
69
|
+
view_component_reducible brings reducer-style (TEA-inspired) state transitions
|
|
70
|
+
to Rails ViewComponent. Server-driven, HTTP-based, no WebSocket required.
|
|
71
|
+
email:
|
|
72
|
+
- matsu.devtool@gmail.com
|
|
73
|
+
executables: []
|
|
74
|
+
extensions: []
|
|
75
|
+
extra_rdoc_files: []
|
|
76
|
+
files:
|
|
77
|
+
- LICENSE.txt
|
|
78
|
+
- README.md
|
|
79
|
+
- lib/view_component_reducible.rb
|
|
80
|
+
- lib/view_component_reducible/adapter/base.rb
|
|
81
|
+
- lib/view_component_reducible/adapter/hidden_field.rb
|
|
82
|
+
- lib/view_component_reducible/adapter/session.rb
|
|
83
|
+
- lib/view_component_reducible/component.rb
|
|
84
|
+
- lib/view_component_reducible/configuration.rb
|
|
85
|
+
- lib/view_component_reducible/dispatch.rb
|
|
86
|
+
- lib/view_component_reducible/dispatch_controller.rb
|
|
87
|
+
- lib/view_component_reducible/engine.rb
|
|
88
|
+
- lib/view_component_reducible/helpers.rb
|
|
89
|
+
- lib/view_component_reducible/msg.rb
|
|
90
|
+
- lib/view_component_reducible/railtie.rb
|
|
91
|
+
- lib/view_component_reducible/runtime.rb
|
|
92
|
+
- lib/view_component_reducible/state/dsl.rb
|
|
93
|
+
- lib/view_component_reducible/state/envelope.rb
|
|
94
|
+
- lib/view_component_reducible/state/schema.rb
|
|
95
|
+
- lib/view_component_reducible/version.rb
|
|
96
|
+
homepage: https://github.com/manabeai/view_component_reducible
|
|
97
|
+
licenses:
|
|
98
|
+
- MIT
|
|
99
|
+
metadata:
|
|
100
|
+
homepage_uri: https://github.com/manabeai/view_component_reducible
|
|
101
|
+
source_code_uri: https://github.com/manabeai/view_component_reducible
|
|
102
|
+
changelog_uri: https://github.com/manabeai/view_component_reducible/blob/main/CHANGELOG.md
|
|
103
|
+
rdoc_options: []
|
|
104
|
+
require_paths:
|
|
105
|
+
- lib
|
|
106
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - ">="
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: 3.1.0
|
|
111
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
112
|
+
requirements:
|
|
113
|
+
- - ">="
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '0'
|
|
116
|
+
requirements: []
|
|
117
|
+
rubygems_version: 3.6.9
|
|
118
|
+
specification_version: 4
|
|
119
|
+
summary: Reducer-based state transitions for Rails ViewComponent
|
|
120
|
+
test_files: []
|