action_form 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a83d578e9d0fbf58ccb568473d8a94e1ccb087df4b8f8e5ee2871f2a4689c7b0
4
- data.tar.gz: e155e0fada05ece59a1e501fe6d6e52ca0cbd652292f0b243e7e59999760562d
3
+ metadata.gz: 0211bd706504ef28c1045cc72f33a2408b554ca0a2b3e922436491a1865a16b9
4
+ data.tar.gz: 87c0249ed1b139d6fea7d26fb9e9d2e220cafa3a045b129530e94b9afb8ef545
5
5
  SHA512:
6
- metadata.gz: 91b6b047b9ac52d7e1d9162ed4b555456466fa7f59e94d54242492f2e515ad352f145aa179027d51753ec78ed1f12150628e23ab156a56de29b29998e1ae968a
7
- data.tar.gz: e64a656d32e8d9ddc91d37d4913c30c513e37854d8f77e368a0acf27e66a1a12a579b8c81bdd4ffd79f4c1ff508850834a256ad93728d762963c8bd317a9c612
6
+ metadata.gz: 817ea2221b6780ed40c769fd37416fae7e2051256b7084b4d4cb612c60fac7288a4dad38316a9af0eba543bf712e07afce8185f33e1d3768e7172cfd81de8103
7
+ data.tar.gz: 32a63869d603e1117ac6bacaf4cd526c7127ba23a703343f537507e31e5739ef9bdce5d6701529413f23f0cd9e8794c3d01b5a4d2954070051eecdcd10a7d881
data/README.md CHANGED
@@ -621,6 +621,313 @@ expect(params).to be_invalid
621
621
 
622
622
  This feature provides a powerful way to customize and extend form definitions while maintaining the declarative nature of ActionForm.
623
623
 
624
+ ### Composition and Owner Delegation
625
+
626
+ ActionForm includes a composition system that allows form components (elements, subforms, and `many` collections) to access methods from their owner (typically the parent form or a custom host object). This promotes code reuse and reduces redundancy by allowing shared logic to be defined in a central location and accessed by multiple form components.
627
+
628
+ #### **How Composition Works**
629
+
630
+ When you create a form, all nested elements and subforms automatically have access to their owner through the `owner` accessor. Methods called on elements or subforms that are not defined locally are automatically delegated up the ownership chain, allowing you to access methods from the parent form or a custom host object.
631
+
632
+ #### **Automatic Ownership Chain**
633
+
634
+ Ownership is automatically established when forms are built:
635
+
636
+ ```ruby
637
+ class ProductForm < ActionForm::Base
638
+ element :name do
639
+ input(type: :text)
640
+ output(type: :string)
641
+
642
+ # This method can call methods on the owner (ProductForm)
643
+ def render?
644
+ name_render? # Delegates to owner.name_render?
645
+ end
646
+ end
647
+
648
+ many :variants, default: [{}] do
649
+ subform do
650
+ element :name do
651
+ input(type: :text)
652
+ output(type: :string)
653
+
654
+ def render?
655
+ variants_name_render? # Delegates to owner.variants_name_render?
656
+ end
657
+ end
658
+
659
+ def render?
660
+ variants_subform_render? # Delegates to owner.variants_subform_render?
661
+ end
662
+ end
663
+ end
664
+
665
+ subform :manufacturer, default: {} do
666
+ element :name do
667
+ input(type: :text)
668
+ output(type: :string)
669
+
670
+ def render?
671
+ manufacturer_name_render? # Delegates to owner.manufacturer_name_render?
672
+ end
673
+ end
674
+ end
675
+
676
+ # Methods accessible by nested components
677
+ def name_render?
678
+ true
679
+ end
680
+
681
+ def variants_name_render?
682
+ true
683
+ end
684
+
685
+ def variants_subform_render?
686
+ true
687
+ end
688
+
689
+ def manufacturer_name_render?
690
+ true
691
+ end
692
+ end
693
+ ```
694
+
695
+ #### **Custom Host Object**
696
+
697
+ You can pass a custom host object when initializing a form, allowing you to separate form logic from business logic:
698
+
699
+ ```ruby
700
+ class HostObject
701
+ def name_render?
702
+ current_user.admin? || form_context == :edit
703
+ end
704
+
705
+ def variants_subform_render?
706
+ feature_enabled?(:variants)
707
+ end
708
+
709
+ def variants_name_render?
710
+ true
711
+ end
712
+
713
+ def variants_price_render?
714
+ pricing_enabled?
715
+ end
716
+
717
+ def manufacturer_name_render?
718
+ manufacturer_feature_enabled?
719
+ end
720
+ end
721
+
722
+ class ProductForm < ActionForm::Base
723
+ element :name do
724
+ input(type: :text)
725
+ output(type: :string)
726
+
727
+ def render?
728
+ name_render? # Calls HostObject#name_render?
729
+ end
730
+ end
731
+
732
+ many :variants, default: [{}] do
733
+ subform do
734
+ element :name do
735
+ input(type: :text)
736
+ output(type: :string)
737
+
738
+ def render?
739
+ variants_name_render? # Calls HostObject#variants_name_render?
740
+ end
741
+ end
742
+
743
+ element :price do
744
+ input(type: :number)
745
+ output(type: :float)
746
+
747
+ def render?
748
+ variants_price_render? # Calls HostObject#variants_price_render?
749
+ end
750
+ end
751
+
752
+ def render?
753
+ variants_subform_render? # Calls HostObject#variants_subform_render?
754
+ end
755
+ end
756
+ end
757
+
758
+ subform :manufacturer, default: {} do
759
+ element :name do
760
+ input(type: :text)
761
+ output(type: :string)
762
+
763
+ def render?
764
+ manufacturer_name_render? # Calls HostObject#manufacturer_name_render?
765
+ end
766
+ end
767
+ end
768
+ end
769
+
770
+ # Use the form with a custom host object
771
+ host = HostObject.new
772
+ product = Product.new(name: "Product 1")
773
+ form = ProductForm.new(object: product, owner: host)
774
+ ```
775
+
776
+ #### **Ownership Chain Traversal**
777
+
778
+ The composition system supports multi-level ownership chains. When a method is called on an element or subform, it searches up the ownership chain until it finds the method:
779
+
780
+ ```ruby
781
+ class GrandparentForm < ActionForm::Base
782
+ def shared_helper
783
+ "grandparent"
784
+ end
785
+ end
786
+
787
+ class ParentForm < ActionForm::Base
788
+ def shared_helper
789
+ "parent"
790
+ end
791
+ end
792
+
793
+ class ChildForm < ActionForm::Base
794
+ element :field do
795
+ input(type: :text)
796
+
797
+ def render?
798
+ shared_helper # Will find ParentForm#shared_helper first
799
+ end
800
+ end
801
+ end
802
+
803
+ # If ChildForm has ParentForm as owner, and ParentForm has GrandparentForm as owner:
804
+ # The method lookup order is: ChildForm -> ParentForm -> GrandparentForm
805
+ ```
806
+
807
+ #### **Accessing Owner Directly**
808
+
809
+ You can access the owner directly using the `owner` accessor:
810
+
811
+ ```ruby
812
+ class ProductForm < ActionForm::Base
813
+ element :discount_code do
814
+ input(type: :text)
815
+ output(type: :string)
816
+
817
+ def disabled?
818
+ # Access owner's methods directly
819
+ owner.current_user && !owner.current_user.admin?
820
+ end
821
+
822
+ def placeholder
823
+ owner.discount_placeholder_text
824
+ end
825
+ end
826
+
827
+ def current_user
828
+ @current_user ||= User.find(session[:user_id])
829
+ end
830
+
831
+ def discount_placeholder_text
832
+ "Enter discount code"
833
+ end
834
+ end
835
+ ```
836
+
837
+ #### **Ownership Hierarchy**
838
+
839
+ The ownership hierarchy is automatically established as follows:
840
+
841
+ - **Top-level form**: Can have a custom `owner` passed during initialization
842
+ - **Elements**: Owner is the form or subform that contains them
843
+ - **Subforms**: Owner is the form that contains them
844
+ - **SubformsCollection (many)**: Owner is the form that contains them
845
+ - **Nested elements in subforms**: Owner is the subform that contains them
846
+ - **Nested subforms**: Owner is the parent form or subform
847
+
848
+ #### **Practical Use Cases**
849
+
850
+ **Conditional Rendering Based on Context:**
851
+ ```ruby
852
+ class OrderForm < ActionForm::Base
853
+ element :admin_notes do
854
+ input(type: :textarea)
855
+ output(type: :string)
856
+
857
+ def render?
858
+ owner.current_user&.admin?
859
+ end
860
+ end
861
+
862
+ def current_user
863
+ @current_user
864
+ end
865
+ end
866
+ ```
867
+
868
+ **Shared Validation Logic:**
869
+ ```ruby
870
+ class RegistrationForm < ActionForm::Base
871
+ element :email do
872
+ input(type: :email)
873
+ output(type: :string)
874
+
875
+ def disabled?
876
+ owner.email_locked?
877
+ end
878
+ end
879
+
880
+ element :email_confirmation do
881
+ input(type: :email)
882
+ output(type: :string)
883
+
884
+ def disabled?
885
+ owner.email_locked?
886
+ end
887
+ end
888
+
889
+ def email_locked?
890
+ @user.persisted? && @user.email_verified?
891
+ end
892
+ end
893
+ ```
894
+
895
+ **Feature Flags:**
896
+ ```ruby
897
+ class SettingsForm < ActionForm::Base
898
+ many :advanced_settings do
899
+ subform do
900
+ element :feature_flag do
901
+ input(type: :checkbox)
902
+ output(type: :bool)
903
+
904
+ def render?
905
+ owner.feature_enabled?(:advanced_settings)
906
+ end
907
+ end
908
+ end
909
+
910
+ def render?
911
+ owner.feature_enabled?(:advanced_settings)
912
+ end
913
+ end
914
+
915
+ def feature_enabled?(feature)
916
+ FeatureFlags.enabled?(feature, current_user)
917
+ end
918
+ end
919
+ ```
920
+
921
+ #### **Benefits**
922
+
923
+ - **Code Reuse**: Share common logic across multiple form components
924
+ - **Separation of Concerns**: Keep business logic in host objects, form logic in forms
925
+ - **Flexibility**: Support conditional rendering and behavior based on context
926
+ - **Maintainability**: Centralize shared logic instead of duplicating it
927
+ - **Testability**: Test host objects separately from form definitions
928
+
929
+ The composition system provides a powerful mechanism for creating flexible, maintainable forms that can adapt to different contexts and requirements.
930
+
624
931
  ### Tagging system
625
932
 
626
933
  ActionForm includes a flexible tagging system that allows you to add custom metadata to form elements and control rendering behavior. Tags serve multiple purposes:
@@ -7,6 +7,7 @@ module ActionForm
7
7
  include ActionForm::SchemaDSL
8
8
  include ActionForm::ElementsDSL
9
9
  include ActionForm::Rendering
10
+ include ActionForm::Composition
10
11
 
11
12
  attr_reader :elements_instances, :scope, :object, :html_options, :errors
12
13
 
@@ -30,13 +31,14 @@ module ActionForm
30
31
  end
31
32
  end
32
33
 
33
- def initialize(object: nil, scope: self.class.scope, params: nil, **html_options)
34
+ def initialize(object: nil, scope: self.class.scope, params: nil, owner: nil, **html_options)
34
35
  super()
35
36
  @object = object
36
37
  @scope ||= scope
37
38
  @params = @scope && params.respond_to?(@scope) ? params.public_send(@scope) : params
38
39
  @html_options = html_options
39
40
  @elements_instances = []
41
+ @owner = owner
40
42
  build_from_object
41
43
  end
42
44
 
@@ -48,7 +50,7 @@ module ActionForm
48
50
  elsif element_definition < ActionForm::Subform
49
51
  @elements_instances << build_subform(name, element_definition)
50
52
  elsif element_definition < ActionForm::Element
51
- @elements_instances << element_definition.new(name, @params || @object, parent_name: @scope)
53
+ @elements_instances << element_definition.new(name, @params || @object, parent_name: @scope, owner: self)
52
54
  end
53
55
  end
54
56
  end
@@ -73,6 +75,7 @@ module ActionForm
73
75
 
74
76
  def build_many_subforms(name, collection_definition)
75
77
  collection = collection_definition.new(name)
78
+ collection.owner = self
76
79
  Array(subform_value(name)).each.with_index do |item, index|
77
80
  collection << build_subform(name, collection_definition.subform_definition, value: item, index: index)
78
81
  end
@@ -93,7 +96,8 @@ module ActionForm
93
96
 
94
97
  def build_subform(name, form_definition, value: subform_value(name), index: nil)
95
98
  html_name = subform_html_name(name, index: index)
96
- form_definition.new(name: name, scope: html_name, model: value, index: index).tap do |subform|
99
+ form_definition.new(name: name, scope: html_name, model: value, index: index,
100
+ owner: self).tap do |subform|
97
101
  subform.helpers = helpers
98
102
  end
99
103
  end
@@ -103,7 +107,8 @@ module ActionForm
103
107
  elements_keys = form_definition.elements.keys.push(:persisted?)
104
108
  values = form_definition.elements.values.map(&:default)
105
109
  value = Struct.new(*elements_keys).new(*values)
106
- form_definition.new(name: name, scope: html_name, model: value, template: true).tap do |subform|
110
+ form_definition.new(name: name, scope: html_name, model: value, template: true,
111
+ owner: self).tap do |subform|
107
112
  subform.helpers = helpers
108
113
  end
109
114
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionForm
4
+ # Provides host–guest association helpers for ActionForm components.
5
+ # When included, it exposes `host_object` and `host_association_name` accessors
6
+ # and delegates `host_*` method calls to the associated host via `method_missing`.
7
+ module Composition
8
+ def self.included(base)
9
+ base.attr_accessor :owner
10
+ base.include(InstanceMethods)
11
+ base.extend(ClassMethods)
12
+ end
13
+
14
+ module ClassMethods # rubocop:disable Style/Documentation
15
+ def part_of(owner_name)
16
+ @owner_name = owner_name
17
+ alias_method owner_name, :owner
18
+ end
19
+ end
20
+
21
+ module InstanceMethods # rubocop:disable Style/Documentation
22
+ def method_missing(name, *attrs, **kwargs, &block)
23
+ if (handler = owners_chain.lazy.detect { |o| o.public_methods.include?(name) })
24
+ handler.public_send(name, *attrs, **kwargs, &block)
25
+ else
26
+ super
27
+ end
28
+ end
29
+
30
+ def respond_to_missing?(method_name, _include_private = false)
31
+ public_methods.detect { |m| m == :owner } || super
32
+ end
33
+
34
+ def owners_chain
35
+ obj = self
36
+ Enumerator.new do |y|
37
+ y << obj = obj.owner while obj.public_methods.include?(:owner)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -4,10 +4,12 @@ module ActionForm
4
4
  # Represents a form element with input/output configuration and HTML attributes
5
5
  # rubocop:disable Metrics/ClassLength
6
6
  class Element
7
+ include ActionForm::Composition
8
+
7
9
  attr_reader :name, :input_options, :output_options, :html_name, :html_id, :select_options, :tags, :errors_messages
8
10
  attr_accessor :helpers
9
11
 
10
- def initialize(name, object, parent_name: nil)
12
+ def initialize(name, object, parent_name: nil, owner: nil)
11
13
  @name = name
12
14
  @object = object
13
15
  @html_name = build_html_name(name, parent_name)
@@ -15,6 +17,7 @@ module ActionForm
15
17
  @tags = self.class.tags_list.dup
16
18
  @errors_messages = extract_errors_messages(object, name)
17
19
  tags.merge!(errors: errors_messages.any?)
20
+ @owner = owner
18
21
  end
19
22
 
20
23
  class << self
@@ -8,6 +8,7 @@ module ActionForm
8
8
  include ActionForm::Rendering
9
9
  include ActionForm::SchemaDSL
10
10
  include ActionForm::ElementsDSL
11
+ include ActionForm::Composition
11
12
 
12
13
  class << self
13
14
  attr_accessor :default
@@ -23,7 +24,7 @@ module ActionForm
23
24
  attr_reader :elements_instances, :tags, :name, :object
24
25
  attr_accessor :helpers
25
26
 
26
- def initialize(name:, scope: nil, model: nil, params: nil, **tags)
27
+ def initialize(name:, scope: nil, model: nil, params: nil, owner: nil, **tags) # rubocop:disable Metrics/ParameterLists
27
28
  super()
28
29
  @name = name
29
30
  @scope = scope
@@ -31,6 +32,7 @@ module ActionForm
31
32
  @params = params
32
33
  @elements_instances = []
33
34
  @tags = tags
35
+ @owner = owner
34
36
  build_from_object
35
37
  end
36
38
 
@@ -38,6 +40,7 @@ module ActionForm
38
40
  self.class.elements.each do |element_name, element_definition|
39
41
  @elements_instances << element_definition.new(element_name, @params || @object, parent_name: @scope)
40
42
  @elements_instances.last.tags.merge!(subform: @name)
43
+ @elements_instances.last.owner = self
41
44
  end
42
45
  end
43
46
 
@@ -5,6 +5,7 @@ module ActionForm
5
5
  class SubformsCollection < ::Phlex::HTML
6
6
  extend Forwardable
7
7
  include ActionForm::Rendering
8
+ include ActionForm::Composition
8
9
 
9
10
  def_delegators :@subforms, :last, :first, :length, :size, :[], :<<
10
11
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActionForm
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/action_form.rb CHANGED
@@ -4,6 +4,7 @@ require "phlex"
4
4
  require "easy_params"
5
5
  require "forwardable"
6
6
  require_relative "action_form/version"
7
+ require_relative "action_form/composition"
7
8
  require_relative "action_form/schema_dsl"
8
9
  require_relative "action_form/elements_dsl"
9
10
  require_relative "action_form/input"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: action_form
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrii Baran
@@ -67,6 +67,7 @@ files:
67
67
  - Rakefile
68
68
  - lib/action_form.rb
69
69
  - lib/action_form/base.rb
70
+ - lib/action_form/composition.rb
70
71
  - lib/action_form/element.rb
71
72
  - lib/action_form/elements_dsl.rb
72
73
  - lib/action_form/input.rb