action_form 0.1.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.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionForm
4
+ # Base class for ActionForm components that provides form building functionality
5
+ # and integrates with Phlex for HTML rendering.
6
+ class Base < ::Phlex::HTML
7
+ include ActionForm::SchemaDSL
8
+ include ActionForm::ElementsDSL
9
+ include ActionForm::Rendering
10
+
11
+ attr_reader :elements_instances, :scope, :object, :html_options, :errors
12
+
13
+ class << self
14
+ attr_writer :elements, :scope
15
+
16
+ def subform_class
17
+ ActionForm::Subform
18
+ end
19
+
20
+ def scope(scope = nil)
21
+ return @scope unless scope
22
+
23
+ @scope = scope
24
+ end
25
+
26
+ def inherited(subclass)
27
+ super
28
+ subclass.elements = elements.dup
29
+ subclass.scope = scope.dup
30
+ end
31
+ end
32
+
33
+ def initialize(object: nil, scope: self.class.scope, params: nil, **html_options)
34
+ super()
35
+ @object = object
36
+ @scope ||= scope
37
+ @params = @scope && params.respond_to?(@scope) ? params.public_send(@scope) : params
38
+ @html_options = html_options
39
+ @elements_instances = []
40
+ build_from_object
41
+ end
42
+
43
+ def build_from_object
44
+ self.class.elements.each do |name, element_definition|
45
+ if element_definition < ActionForm::SubformsCollection
46
+ @elements_instances << build_many_subforms(name, element_definition)
47
+ @elements_instances.last << build_subform_template(name, element_definition.subform_definition)
48
+ elsif element_definition < ActionForm::Subform
49
+ @elements_instances << build_subform(name, element_definition)
50
+ elsif element_definition < ActionForm::Element
51
+ @elements_instances << element_definition.new(name, @params || @object, parent_name: @scope)
52
+ end
53
+ end
54
+ end
55
+
56
+ def view_template
57
+ render_form do
58
+ render_elements
59
+ render_submit
60
+ end
61
+ end
62
+
63
+ def render_in(view_context)
64
+ @_view_context = view_context
65
+ view_context.render html: call.html_safe
66
+ end
67
+
68
+ def helpers
69
+ @_view_context
70
+ end
71
+
72
+ private
73
+
74
+ def build_many_subforms(name, collection_definition)
75
+ collection = collection_definition.new(name)
76
+ Array(subform_value(name)).each.with_index do |item, index|
77
+ collection << build_subform(name, collection_definition.subform_definition, value: item, index: index)
78
+ end
79
+ collection
80
+ end
81
+
82
+ def subform_html_name(name, index: nil)
83
+ if index
84
+ @scope ? "#{@scope}[#{name}][#{index}]" : "[#{name}][#{index}]"
85
+ else
86
+ @scope ? "#{@scope}[#{name}]" : name
87
+ end
88
+ end
89
+
90
+ def subform_value(name)
91
+ @object.public_send(name)
92
+ end
93
+
94
+ def build_subform(name, form_definition, value: subform_value(name), index: nil)
95
+ html_name = subform_html_name(name, index: index)
96
+ form_definition.new(name: name, scope: html_name, model: value, index: index).tap do |subform|
97
+ subform.helpers = helpers
98
+ end
99
+ end
100
+
101
+ def build_subform_template(name, form_definition)
102
+ html_name = subform_html_name(name, index: "NEW_RECORD")
103
+ elements_keys = form_definition.elements.keys.push(:persisted?)
104
+ value = Struct.new(*elements_keys).new
105
+ form_definition.new(name: name, scope: html_name, model: value, template: true).tap do |subform|
106
+ subform.helpers = helpers
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionForm
4
+ # Represents a form element with input/output configuration and HTML attributes
5
+ # rubocop:disable Metrics/ClassLength
6
+ class Element
7
+ attr_reader :name, :input_options, :output_options, :html_name, :html_id, :select_options, :tags, :errors_messages
8
+ attr_accessor :helpers
9
+
10
+ def initialize(name, object, parent_name: nil)
11
+ @name = name
12
+ @object = object
13
+ @html_name = build_html_name(name, parent_name)
14
+ @html_id = build_html_id(name, parent_name)
15
+ @tags = self.class.tags_list.dup
16
+ @errors_messages = extract_errors_messages(object, name)
17
+ tags.merge!(errors: errors_messages.any?)
18
+ end
19
+
20
+ class << self
21
+ def label_options
22
+ @label_options ||= [{ text: nil, display: true }, {}]
23
+ end
24
+
25
+ def select_options
26
+ @select_options ||= []
27
+ end
28
+
29
+ def output_options
30
+ @output_options ||= {}
31
+ end
32
+
33
+ def input_options
34
+ @input_options ||= {}
35
+ end
36
+
37
+ def tags_list
38
+ @tags_list ||= {}
39
+ end
40
+
41
+ def input(type:, **options)
42
+ @input_options = { type: type }.merge(options)
43
+ tags_list.merge!(input: type)
44
+ end
45
+
46
+ def output(type:, **options)
47
+ @output_options = { type: type }.merge(options)
48
+ tags_list.merge!(output: type)
49
+ end
50
+
51
+ def options(collection)
52
+ @select_options = collection
53
+ tags_list.merge!(options: true)
54
+ end
55
+
56
+ def label(text: nil, display: true, **html_options)
57
+ @label_options = [{ text: text, display: display }, html_options]
58
+ end
59
+
60
+ def tags(**tags_list)
61
+ tags_list.merge!(tags_list)
62
+ end
63
+ end
64
+
65
+ def label_text
66
+ self.class.label_options.first[:text] || name.to_s.tr("_", " ").capitalize
67
+ end
68
+
69
+ def label_html_attributes
70
+ { for: html_id }.merge(self.class.label_options.last)
71
+ end
72
+
73
+ def html_value
74
+ if input_type == :checkbox
75
+ value ? "1" : "0"
76
+ elsif detached?
77
+ self.class.input_options[:value]
78
+ elsif object.is_a?(EasyParams::Base)
79
+ object.public_send(name)
80
+ else
81
+ value.to_s
82
+ end
83
+ end
84
+
85
+ def html_checked
86
+ return unless input_type == :checkbox
87
+
88
+ value
89
+ end
90
+
91
+ def input_html_attributes # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
92
+ attrs = self.class.input_options.dup
93
+ attrs[:name] ||= html_name
94
+ attrs[:id] ||= html_id
95
+ attrs[:value] ||= html_value
96
+ attrs[:checked] ||= html_checked
97
+ attrs[:disabled] ||= disabled?
98
+ attrs[:readonly] ||= readonly?
99
+ unless input_tag?
100
+ attrs.delete(:type)
101
+ attrs.delete(:value)
102
+ end
103
+ attrs
104
+ end
105
+
106
+ def value
107
+ return unless object
108
+
109
+ object.public_send(name)
110
+ end
111
+
112
+ def render?
113
+ true
114
+ end
115
+
116
+ def detached?
117
+ false
118
+ end
119
+
120
+ def disabled?
121
+ false
122
+ end
123
+
124
+ def readonly?
125
+ false
126
+ end
127
+
128
+ def input_type
129
+ self.class.input_options[:type].to_sym
130
+ end
131
+
132
+ private
133
+
134
+ attr_reader :object
135
+
136
+ def input_tag?
137
+ !%i[select textarea].include?(input_type)
138
+ end
139
+
140
+ def build_html_name(name, parent_name)
141
+ parent_name ? "#{parent_name}[#{name}]" : name
142
+ end
143
+
144
+ def build_html_id(name, parent_name)
145
+ parent_name.to_s.split(/\[|\]/).reject(&:blank?).push(name).compact.join("_")
146
+ end
147
+
148
+ def extract_errors_messages(object, name)
149
+ (object.respond_to?(:errors) && object&.errors&.messages_for(name)) || []
150
+ end
151
+ end
152
+ # rubocop:enable Metrics/ClassLength
153
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionForm
4
+ # Provides a DSL for defining form elements with input and output configurations.
5
+ # This module allows form classes to define elements using a simple block syntax.
6
+ # Elements can be configured with input types, output formats, labels and other options.
7
+ #
8
+ # @example
9
+ # class UserForm < ActionForm::Base
10
+ # element :name do
11
+ # input type: :text
12
+ # label text: "Full Name"
13
+ # end
14
+ # end
15
+ module ElementsDSL
16
+ def self.included(base)
17
+ base.extend(ClassMethods)
18
+ end
19
+
20
+ module ClassMethods # rubocop:disable Style/Documentation
21
+ def elements
22
+ @elements ||= {}
23
+ end
24
+
25
+ # TODO: add support for outputless elements
26
+ def element(name, &block)
27
+ elements[name] = Class.new(ActionForm::Element)
28
+ elements[name].class_eval(&block)
29
+ end
30
+
31
+ def many(name, default: nil, &block)
32
+ subform_definition = Class.new(ActionForm::SubformsCollection)
33
+ subform_definition.host_class = self
34
+ subform_definition.class_eval(&block) if block
35
+ elements[name] = subform_definition
36
+ elements[name].default = default if default
37
+ end
38
+
39
+ def subform(name, default: nil, &block)
40
+ elements[name] = Class.new(subform_class)
41
+ elements[name].class_eval(&block)
42
+ elements[name].default = default if default
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionForm
4
+ # Represents a form element with input/output configuration and HTML attributes
5
+ class Input < Phlex::HTML
6
+ attr_reader :element, :html_attributes
7
+
8
+ def initialize(element, **html_attributes)
9
+ super()
10
+ @element = element
11
+ @html_attributes = html_attributes
12
+ end
13
+
14
+ def view_template
15
+ if %i[checkbox radio select textarea].include?(element.input_type)
16
+ send("render_#{element.input_type}")
17
+ else
18
+ input(**mix(element.input_html_attributes, html_attributes))
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def render_checkbox # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
25
+ if element.class.select_options.any?
26
+ element.class.select_options.each do |value, label_text|
27
+ checkbox_id = "#{element.html_id}_#{value}"
28
+ checkbox_attrs = element.input_html_attributes.merge(
29
+ value: value,
30
+ id: checkbox_id,
31
+ name: "#{element.html_name}[]",
32
+ checked: Array(element.value).include?(value)
33
+ )
34
+
35
+ input(**mix(checkbox_attrs, html_attributes))
36
+ label(**element.label_html_attributes, for: checkbox_id) { label_text }
37
+ end
38
+ else
39
+ input(name: element.html_name, type: "hidden", value: "0", autocomplete: "off")
40
+ input(**mix(element.input_html_attributes, html_attributes), type: "checkbox", value: "1")
41
+ end
42
+ end
43
+
44
+ def render_radio
45
+ element.class.select_options.each do |value, label_text|
46
+ label(**element.label_html_attributes) { label_text }
47
+ input(**mix(element.input_html_attributes, html_attributes), type: "radio", value: value,
48
+ checked: value == element.value)
49
+ end
50
+ end
51
+
52
+ def render_select
53
+ select(**mix(element.input_html_attributes, html_attributes)) do
54
+ element.class.select_options.each do |value, label_text|
55
+ option(value: value, selected: option_selected?(value)) { label_text }
56
+ end
57
+ end
58
+ end
59
+
60
+ def render_textarea
61
+ textarea(**mix(element.input_html_attributes, html_attributes)) { element.value }
62
+ end
63
+
64
+ def option_selected?(value)
65
+ if element.class.input_options[:multiple]
66
+ Array(element.value).include?(value)
67
+ else
68
+ value == element.value
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+
5
+ module ActionForm
6
+ module Rails
7
+ # RailsForm class for ActionForm that handles Rails-specific form rendering.
8
+ # It integrates with Rails form helpers and provides a Rails-friendly interface
9
+ # for building forms.
10
+ class Base < ActionForm::Base
11
+ include ActionForm::Rails::Rendering
12
+
13
+ def self.subform_class
14
+ ActionForm::Rails::Subform
15
+ end
16
+
17
+ attr_reader :namespaced_model
18
+
19
+ def initialize(model: nil, scope: self.class.scope, params: nil, **html_options)
20
+ @namespaced_model = model
21
+ @object = model.is_a?(Array) ? Array(model).last : model
22
+ @scope = scope.nil? && @object.nil? ? nil : (scope || param_key)
23
+ super(object: @object, scope: @scope, params: params, **html_options)
24
+ end
25
+
26
+ class << self
27
+ def resource_model(model = nil)
28
+ return @resource_model unless model
29
+
30
+ @resource_model = model
31
+ @scope = model.model_name.param_key.to_sym
32
+ end
33
+
34
+ def params_definition(scope: self.scope)
35
+ return super unless scope
36
+
37
+ @params_definitions ||= Hash.new do |h, key|
38
+ h[key] = begin
39
+ klass = super
40
+ Class.new(params_class) { has scope, klass }
41
+ end
42
+ end
43
+ @params_definitions[scope]
44
+ end
45
+
46
+ def many(name, default: nil, &block)
47
+ super
48
+ elements[name].subform_definition.add_primary_key_element
49
+ elements[name].subform_definition.add_delete_element
50
+ end
51
+
52
+ def subform(name, default: nil, &block)
53
+ super
54
+ elements[name].add_primary_key_element
55
+ end
56
+ end
57
+
58
+ def view_template
59
+ render_form do
60
+ render_elements
61
+ render_submit
62
+ end
63
+ end
64
+
65
+ def with_params(form_params)
66
+ self.class.new(model: @namespaced_model, scope: @scope, params: form_params, **html_options)
67
+ end
68
+
69
+ private
70
+
71
+ def subform_html_name(name, index: nil)
72
+ if index
73
+ @scope ? "#{@scope}[#{name}_attributes][#{index}]" : "[#{name}_attributes][#{index}]"
74
+ else
75
+ @scope ? "#{@scope}[#{name}_attributes]" : "#{name}_attributes"
76
+ end
77
+ end
78
+
79
+ def subform_value(name)
80
+ if @params
81
+ @params.send("#{name}_attributes")
82
+ else
83
+ @object.public_send(name)
84
+ end
85
+ end
86
+
87
+ def model_name
88
+ @object&.model_name
89
+ end
90
+
91
+ def param_key
92
+ model_name.param_key.to_sym
93
+ end
94
+
95
+ def resource_action
96
+ return :search if @object.nil?
97
+
98
+ @object.persisted? ? :update : :create
99
+ end
100
+
101
+ def http_method
102
+ return "get" if @object.nil?
103
+
104
+ @object.persisted? ? "patch" : "post"
105
+ end
106
+
107
+ def html_action
108
+ html_options[:action] ||= helpers.polymorphic_path(@namespaced_model)
109
+ end
110
+
111
+ def html_method
112
+ html_options[:method] = html_options[:method].to_s.downcase == "get" ? "get" : "post"
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionForm
4
+ module Rails
5
+ # Rendering module for ActionForm Rails integration that provides form rendering functionality.
6
+ # Handles rendering of forms with error messages, authenticity tokens, UTF-8 encoding,
7
+ # and other Rails-specific form requirements. Also provides helper methods for rendering
8
+ # submit buttons and form elements.
9
+ module Rendering
10
+ def render_form(&block)
11
+ form(**{ method: html_method, action: html_action, "accept-charset" => "UTF-8" }, **@html_options) do
12
+ render_utf8_input
13
+ render_authenticity_token
14
+ render_method_input
15
+ yield if block
16
+ end
17
+ end
18
+
19
+ def render_authenticity_token
20
+ input(name: "authenticity_token", type: "hidden", value: helpers.form_authenticity_token)
21
+ end
22
+
23
+ def render_method_input
24
+ input(name: "_method", type: "hidden", value: http_method, autocomplete: "off")
25
+ end
26
+
27
+ def render_utf8_input
28
+ input(name: "utf8", type: "hidden", value: "✓", autocomplete: "off")
29
+ end
30
+
31
+ def render_submit(**html_attributes)
32
+ input(name: "commit", type: "submit", value: submit_value, **html_attributes)
33
+ end
34
+
35
+ private
36
+
37
+ def submit_value
38
+ "#{resource_action.to_s.capitalize} #{model_name}"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionForm
4
+ module Rails
5
+ # Subform class for ActionForm that handles nested form structures.
6
+ # It allows building forms within forms, supporting has_one and has_many relationships.
7
+ # Includes schema and element DSL functionality for defining form elements.
8
+ class Subform < ActionForm::Subform
9
+ class << self
10
+ def add_primary_key_element
11
+ return if elements.key?(:id)
12
+
13
+ element :id do
14
+ input(type: :hidden, autocomplete: :off)
15
+ output(type: :integer)
16
+
17
+ def render?
18
+ object.persisted? || (object.is_a?(EasyParams::Base) && !object.id.nil?)
19
+ end
20
+ end
21
+ end
22
+
23
+ def add_delete_element
24
+ element :_destroy do
25
+ input(type: :hidden, autocomplete: :off, value: "0")
26
+ output(type: :bool)
27
+
28
+ def render?
29
+ object.persisted? || (object.is_a?(EasyParams::Base) && !object._destroy.nil?)
30
+ end
31
+
32
+ def detached?
33
+ true
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ def html_class
40
+ object.id.nil? ? "new_#{name}" : super
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionForm
4
+ # Provides methods for rendering form elements and forms
5
+ module Rendering
6
+ def render_elements(elements = elements_instances) # rubocop:disable Metrics/MethodLength
7
+ elements.select(&:render?).each do |element|
8
+ element.helpers = helpers
9
+ if element.is_a?(SubformsCollection)
10
+ render_many_subforms(element)
11
+ elsif element.is_a?(Subform)
12
+ render_subform(element)
13
+ elsif element.input_type == :hidden
14
+ input(**element.input_html_attributes)
15
+ else
16
+ render_element(element)
17
+ end
18
+ end
19
+ end
20
+
21
+ def render_element(element)
22
+ render_label(element)
23
+ render_input(element)
24
+ render_inline_errors(element) if element.tags[:errors]
25
+ end
26
+
27
+ def render_label(element)
28
+ return if hide_label?(element)
29
+
30
+ label(**element.label_html_attributes) { element.label_text }
31
+ end
32
+
33
+ def render_input(element, **html_attributes)
34
+ render Input.new(element, **html_attributes)
35
+ end
36
+
37
+ def render_inline_errors(element)
38
+ div(class: "error-messages") { element.errors_messages.join(", ") }
39
+ end
40
+
41
+ def render_form(**html_attributes, &block)
42
+ form(**@html_options, **html_attributes, &block)
43
+ end
44
+
45
+ def render_subform(subform)
46
+ render(subform)
47
+ end
48
+
49
+ def render_many_subforms(subforms)
50
+ render(subforms)
51
+ end
52
+
53
+ def render_submit(**html_attributes)
54
+ input(name: "commit", type: "submit", value: "Submit", **html_attributes)
55
+ end
56
+
57
+ def render_remove_subform_button(**html_attributes, &block)
58
+ a(**html_attributes, onclick: safe("easyFormRemoveSubform(event)"), &block)
59
+ end
60
+
61
+ def render_new_subform_button(**html_attributes, &block)
62
+ a(**html_attributes, onclick: safe("easyFormAddSubform(event)"), &block)
63
+ end
64
+
65
+ private
66
+
67
+ def hide_label?(element)
68
+ return true unless element.class.label_options.first[:display]
69
+
70
+ element.input_type == :hidden ||
71
+ (%i[checkbox radio].include?(element.input_type) && element.class.select_options.any?)
72
+ end
73
+ end
74
+ end