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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 63ab84272ee25669a3647287a779462deb0ea3ae4b12327cc7b5b632a3bcfb08
4
- data.tar.gz: 57ce9c56c9aa55d61597f22faab956ffcc9865f93e9296ba08f6c0881cf84fc1
3
+ metadata.gz: 5505b895dc07055d3d09ad5e466316aadab92321fd2b8a71c3889cc1284059f2
4
+ data.tar.gz: 66757165853350bb660d16605c935ec5072eb01aedf4dfaf62419ab4d1af5439
5
5
  SHA512:
6
- metadata.gz: bd8662871ea30fd81a8ffc695067322fc7ff23fdaf625cd1e43bedab36bc198962aa7210d320ec79532fb88f784b03492ab23112b3fdfbc9a3f906cc7762e9a0
7
- data.tar.gz: e8fdda9358af6377de808a9beaa016d9e3603c4b167978c7230e882f7a551e27e2840d2239bb777c1e6ef56299f443a8e6e6af9374930da8683c6269b18e63b6
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 stays inside ViewComponent—no extra endpoints, controllers, WebSockets, or JS frameworks required.
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 "active_support"
4
- require "active_support/message_verifier"
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: "SHA256", serializer: JSON)
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("vcr_state")
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 "active_support"
4
- require "active_support/message_verifier"
5
- require "securerandom"
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: "SHA256", serializer: JSON)
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({ "k" => key })
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("vcr_state")
28
+ signed = request.params.fetch('vcr_state')
29
29
  payload = verifier.verify(signed)
30
- key = payload.fetch("k")
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=>Hash}]
33
+ # @return [Hash{String=>Object}]
31
34
  def vcr_state
32
- return { "data" => {}, "meta" => {} } if vcr_envelope.nil?
35
+ return {} if vcr_envelope.nil?
33
36
 
34
37
  schema = self.class.vcr_state_schema
35
- data, meta = schema.build(vcr_envelope["data"], vcr_envelope["meta"])
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(controller, adapter_class: nil)
18
+ def adapter_for(_controller, adapter_class: nil)
19
19
  resolved_secret = secret || default_secret
20
- raise "ViewComponentReducible secret is missing" if resolved_secret.nil?
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 "erb"
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?("</head>") ? html.sub("</head>", "#{injection}</head>") : (injection + html)
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 "action_controller"
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("vcr_target_path", envelope["path"])
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["vcr_partial"] == "1"
35
+ if params['vcr_partial'] == '1'
36
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"
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: "text/html"
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: "Invalid state signature"
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 "rails/engine"
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 "json"
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: "root", url: "/vcr/dispatch", &block)
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("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) : "")
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.querySelectorAll('input[name="vcr_state"]').forEach(function(input) {
75
- input.value = payload.state;
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 "json"
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("vcr_msg_type")
13
- payload_json = params["vcr_msg_payload"]
14
- payload = payload_json && payload_json != "" ? JSON.parse(payload_json) : {}
15
- new(type:, payload:)
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 "rails/railtie"
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 "view_component_reducible.helpers" do
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["root"])
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["root"])
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["path"]
36
+ if target_path == env['path']
37
37
  apply_reducer(root_klass, env, msg, controller)
38
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)
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
- 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"]
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
- return env if effects.nil? || effects.empty?
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 "Too many effect steps" if steps > MAX_EFFECT_STEPS
75
+ raise 'Too many effect steps' if steps > MAX_EFFECT_STEPS
68
76
 
69
- follow_msg = eff.call(controller: controller, envelope: env)
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
- 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))
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["path"]
108
+ return [component_klass, env] if target_path == env['path']
92
109
 
93
- child = env["children"].fetch(target_path) { raise KeyError, "Unknown path: #{target_path}" }
94
- child_klass = ViewComponentReducible.registry.fetch(child["root"])
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 data field.
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:, 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)
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: "root")
11
+ def self.initial(root_component_klass, path: 'root')
12
12
  schema = root_component_klass.vcr_state_schema
13
- data, meta = schema.defaults
13
+ data = schema.defaults
14
14
  {
15
- "v" => 1,
16
- "root" => root_component_klass.vcr_id,
17
- "path" => path,
18
- "data" => data,
19
- "children" => {},
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, :kind, keyword_init: true) # kind: :data or :meta
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:, kind:)
19
- @fields << Field.new(name:, default:, kind:)
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 data_hash [Hash]
24
- # @param meta_hash [Hash]
25
- # @return [Array<Hash{String=>Object}>] [data, meta]
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 = (field.kind == :meta ? meta_hash : data_hash) || {}
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
- if field.kind == :meta
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
- [data, meta]
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 [Array<Hash{String=>Object}>] [data, meta]
71
+ # @return [Hash{String=>Object}]
50
72
  def defaults
51
- build({}, {})
73
+ build({})
52
74
  end
53
75
  end
54
76
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ViewComponentReducible
4
- VERSION = "0.1.1"
4
+ VERSION = '0.1.3'
5
5
  end
@@ -1,21 +1,21 @@
1
1
  # frozen_string_literal: true
2
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"
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.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.1.0
125
+ version: 3.2.0
112
126
  required_rubygems_version: !ruby/object:Gem::Requirement
113
127
  requirements:
114
128
  - - ">="