phlex-forms 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.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +170 -0
  5. data/app/javascript/phlex_forms/controllers/validations/acceptance_controller.js +23 -0
  6. data/app/javascript/phlex_forms/controllers/validations/base_controller.js +166 -0
  7. data/app/javascript/phlex_forms/controllers/validations/confirmation_controller.js +27 -0
  8. data/app/javascript/phlex_forms/controllers/validations/exclusion_controller.js +19 -0
  9. data/app/javascript/phlex_forms/controllers/validations/form_controller.js +43 -0
  10. data/app/javascript/phlex_forms/controllers/validations/format_controller.js +23 -0
  11. data/app/javascript/phlex_forms/controllers/validations/inclusion_controller.js +19 -0
  12. data/app/javascript/phlex_forms/controllers/validations/length_controller.js +95 -0
  13. data/app/javascript/phlex_forms/controllers/validations/numericality_controller.js +59 -0
  14. data/app/javascript/phlex_forms/controllers/validations/presence_controller.js +14 -0
  15. data/app/javascript/phlex_forms/i18n.js +40 -0
  16. data/app/javascript/phlex_forms/messages.js +100 -0
  17. data/config/importmap.rb +21 -0
  18. data/config/locales/de.yml +12 -0
  19. data/config/locales/en.yml +14 -0
  20. data/config/locales/sv.yml +12 -0
  21. data/config/rubocop.yml +20 -0
  22. data/lib/forms/checkbox.rb +39 -0
  23. data/lib/forms/choices_select.rb +132 -0
  24. data/lib/forms/collection_check_box.rb +26 -0
  25. data/lib/forms/collection_check_box_builder.rb +26 -0
  26. data/lib/forms/collection_label.rb +20 -0
  27. data/lib/forms/email_field.rb +11 -0
  28. data/lib/forms/field.rb +222 -0
  29. data/lib/forms/field_error.rb +26 -0
  30. data/lib/forms/field_hint.rb +24 -0
  31. data/lib/forms/fields_for_builder.rb +58 -0
  32. data/lib/forms/file_input.rb +29 -0
  33. data/lib/forms/form.rb +225 -0
  34. data/lib/forms/form_control.rb +42 -0
  35. data/lib/forms/input.rb +35 -0
  36. data/lib/forms/label.rb +32 -0
  37. data/lib/forms/password_field.rb +11 -0
  38. data/lib/forms/radio.rb +29 -0
  39. data/lib/forms/range.rb +33 -0
  40. data/lib/forms/rich_textarea.rb +43 -0
  41. data/lib/forms/select.rb +73 -0
  42. data/lib/forms/submit.rb +62 -0
  43. data/lib/forms/textarea.rb +27 -0
  44. data/lib/forms/time_zone_select.rb +52 -0
  45. data/lib/forms/toggle.rb +39 -0
  46. data/lib/forms/validations/introspector.rb +224 -0
  47. data/lib/forms/validations/manual_rules.rb +77 -0
  48. data/lib/forms/wrapped_input.rb +67 -0
  49. data/lib/phlex-forms.rb +4 -0
  50. data/lib/phlex_forms/builder.rb +153 -0
  51. data/lib/phlex_forms/class_merge.rb +56 -0
  52. data/lib/phlex_forms/configuration.rb +49 -0
  53. data/lib/phlex_forms/delegated_field.rb +51 -0
  54. data/lib/phlex_forms/engine.rb +35 -0
  55. data/lib/phlex_forms/inline_icons.rb +36 -0
  56. data/lib/phlex_forms/rubocop.rb +14 -0
  57. data/lib/phlex_forms/version.rb +5 -0
  58. data/lib/phlex_forms.rb +80 -0
  59. data/lib/rubocop/phlex_forms/cop/legacy_form_method.rb +103 -0
  60. data/lib/rubocop/phlex_forms/cop/raw_form.rb +51 -0
  61. metadata +181 -0
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # Convenience: an Input pre-typed as password.
5
+ # PasswordField(:primary, name: "user[password]")
6
+ class PasswordField < Input
7
+ def initialize(*, placeholder: "••••••••", autocomplete: "current-password", **)
8
+ super(*, type: "password", placeholder:, autocomplete:, **)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A model-bound radio input, delegating to DaisyUI::Radio.
5
+ class Radio < Phlex::HTML
6
+ include PhlexForms::DelegatedField
7
+
8
+ def initialize(*modifiers, name: nil, id: nil, value: nil, checked: false,
9
+ error: false, disabled: false, required: false, **attributes)
10
+ @modifiers = normalize_modifiers(modifiers)
11
+ @name = name
12
+ @id = id
13
+ @value = value
14
+ @checked = checked
15
+ @error = error
16
+ @disabled = disabled
17
+ @required = required
18
+ @full_width = false
19
+ @attributes = attributes
20
+ super()
21
+ end
22
+
23
+ def view_template
24
+ attrs = binding_attributes
25
+ attrs[:checked] = true if @checked
26
+ render DaisyUI::Radio.new(*daisy_modifiers, value: @value, **attrs)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A model-bound range slider, delegating to DaisyUI::Range.
5
+ class Range < Phlex::HTML
6
+ include PhlexForms::DelegatedField
7
+
8
+ def initialize(*modifiers, name: nil, id: nil, value: nil, min: 0, max: 100,
9
+ step: 1, error: false, disabled: false, full_width: true, **attributes)
10
+ @modifiers = normalize_modifiers(modifiers)
11
+ @name = name
12
+ @id = id
13
+ @value = value
14
+ @min = min
15
+ @max = max
16
+ @step = step
17
+ @error = error
18
+ @disabled = disabled
19
+ @required = false
20
+ @full_width = full_width
21
+ @attributes = attributes
22
+ super()
23
+ end
24
+
25
+ def view_template
26
+ render DaisyUI::Range.new(
27
+ *daisy_modifiers,
28
+ value: @value, min: @min, max: @max, step: @step,
29
+ **binding_attributes
30
+ )
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A rich-text editor backed by the Lexxy `<lexxy-editor>` custom element for
5
+ # ActionText. Optional feature: requires ActionText/Lexxy in the host app.
6
+ # Renders an editor bound to the field; when the value is an ActionText::RichText
7
+ # its HTML body is used as the initial content.
8
+ class RichTextarea < Phlex::HTML
9
+ register_element :lexxy_editor
10
+
11
+ def initialize(*modifiers, name:, id: nil, value: nil, **options)
12
+ @modifiers = modifiers
13
+ @name = name
14
+ @id = id || name.to_s.gsub(/[\[\]]/, "_").gsub(/_+$/, "")
15
+ @value = value
16
+ @options = options
17
+ super()
18
+ end
19
+
20
+ def view_template
21
+ lexxy_editor(
22
+ name: @name,
23
+ id: @id,
24
+ value: formatted_value,
25
+ class: editor_classes,
26
+ **@options.except(:class, :error)
27
+ )
28
+ end
29
+
30
+ private
31
+
32
+ def formatted_value
33
+ return nil if @value.nil? || (@value.respond_to?(:blank?) && @value.blank?)
34
+
35
+ content = @value.respond_to?(:body) ? @value.body&.to_html : @value.to_s
36
+ content.to_s.empty? ? nil : "<div>#{content}</div>"
37
+ end
38
+
39
+ def editor_classes
40
+ [@options[:class], "lexxy-content"].compact.join(" ")
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A model-bound native `<select>`. Delegates the element + variants to
5
+ # DaisyUI::Select (daisyui v5 puts the `select` class on the element itself)
6
+ # and renders the options from `choices` inside its block.
7
+ class Select < Phlex::HTML
8
+ include PhlexForms::DelegatedField
9
+
10
+ def initialize(*modifiers, name: nil, id: nil, choices: [], selected: nil,
11
+ include_blank: false, prompt: nil, error: false, disabled: false,
12
+ required: false, full_width: true, **attributes)
13
+ @modifiers = normalize_modifiers(modifiers)
14
+ @name = name
15
+ @id = id
16
+ @choices = choices
17
+ @selected = selected
18
+ @include_blank = include_blank
19
+ @prompt = prompt
20
+ @error = error
21
+ @disabled = disabled
22
+ @required = required
23
+ @full_width = full_width
24
+ @attributes = attributes
25
+ super()
26
+ end
27
+
28
+ def view_template
29
+ render DaisyUI::Select.new(*daisy_modifiers, **binding_attributes) do |el|
30
+ render_prompt(el) if @prompt || @include_blank
31
+ render_choices(el)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # The options are rendered inside DaisyUI::Select's block, so option/optgroup
38
+ # resolve to the daisyui component's Phlex methods (yielded as `s`).
39
+ def render_prompt(el)
40
+ text = @prompt || (@include_blank.is_a?(String) ? @include_blank : "")
41
+ el.option(value: "", selected: @selected.blank?) { text }
42
+ end
43
+
44
+ def render_choices(el)
45
+ case @choices
46
+ when Hash then render_hash_choices(el, @choices)
47
+ else render_array_choices(el, Array(@choices))
48
+ end
49
+ end
50
+
51
+ def render_array_choices(el, choices)
52
+ choices.each do |choice|
53
+ if choice.is_a?(Array)
54
+ el.option(value: choice.last.to_s, selected: @selected.to_s == choice.last.to_s) { choice.first.to_s }
55
+ else
56
+ el.option(value: choice.to_s, selected: @selected.to_s == choice.to_s) { choice.to_s }
57
+ end
58
+ end
59
+ end
60
+
61
+ def render_hash_choices(el, choices)
62
+ choices.each do |label, value|
63
+ if value.is_a?(Array) || value.is_a?(Hash)
64
+ el.optgroup(label:) do
65
+ value.is_a?(Array) ? render_array_choices(el, value) : render_hash_choices(el, value)
66
+ end
67
+ else
68
+ el.option(value: value.to_s, selected: @selected.to_s == value.to_s) { label.to_s }
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # Submit button, delegating markup and variants to DaisyUI::Button. Defaults its
5
+ # label from the model's persistence state (Create/Update <Model>) via i18n, or
6
+ # takes explicit text.
7
+ #
8
+ # Submit() # "Create User" / "Update User"
9
+ # Submit(:primary, :lg) # default text, modifiers
10
+ # Submit("Save") # custom text
11
+ # Submit("Save", :primary) # custom text + modifiers
12
+ class Submit < Phlex::HTML
13
+ def initialize(*args, model: nil, disabled: false, **attributes)
14
+ if args.first.is_a?(String) || args.first.nil?
15
+ @text = args.first
16
+ @modifiers = args.drop(1)
17
+ else
18
+ @text = nil
19
+ @modifiers = args
20
+ end
21
+ @model = model
22
+ @disabled = disabled
23
+ @attributes = attributes
24
+ super()
25
+ end
26
+
27
+ def view_template(&block)
28
+ attrs = { type: "submit", **@attributes }
29
+ attrs[:disabled] = true if @disabled
30
+ render DaisyUI::Button.new(*@modifiers, **attrs) do
31
+ block ? yield : button_text
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def button_text
38
+ @text || default_text
39
+ end
40
+
41
+ def default_text
42
+ action = persisted? ? "update" : "create"
43
+ if (name = model_name)
44
+ I18n.t("cmd.#{action}_model", model: name, default: I18n.t("cmd.#{action}"))
45
+ else
46
+ I18n.t("cmd.#{action}")
47
+ end
48
+ end
49
+
50
+ def persisted?
51
+ @model.respond_to?(:persisted?) && @model.persisted?
52
+ end
53
+
54
+ def model_name
55
+ if @model.respond_to?(:model_name)
56
+ @model.model_name.human
57
+ elsif @model.respond_to?(:class) && @model.class.respond_to?(:model_name)
58
+ @model.class.model_name.human
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A model-bound textarea. Thin form-binding layer over DaisyUI::Textarea.
5
+ class Textarea < Phlex::HTML
6
+ include PhlexForms::DelegatedField
7
+
8
+ def initialize(*modifiers, name: nil, id: nil, value: nil, error: false,
9
+ disabled: false, required: false, rows: 4, full_width: true, **attributes)
10
+ @modifiers = normalize_modifiers(modifiers)
11
+ @name = name
12
+ @id = id
13
+ @value = value
14
+ @error = error
15
+ @disabled = disabled
16
+ @required = required
17
+ @rows = rows
18
+ @full_width = full_width
19
+ @attributes = attributes
20
+ super()
21
+ end
22
+
23
+ def view_template
24
+ render DaisyUI::Textarea.new(*daisy_modifiers, rows: @rows, **binding_attributes) { @value }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A time-zone `<select>` populated by Rails' time_zone_options_for_select,
5
+ # delegating the styling to DaisyUI::Select and wiring the `tz` Stimulus
6
+ # controller (which auto-selects the browser's zone when none is set).
7
+ #
8
+ # Requires phlex-rails' TimeZoneOptionsForSelect helper (a Rails host); outside
9
+ # Rails, pass explicit `choices` to a plain Forms::Select instead.
10
+ class TimeZoneSelect < Phlex::HTML
11
+ include PhlexForms::DelegatedField
12
+
13
+ include Phlex::Rails::Helpers::TimeZoneOptionsForSelect if defined?(Phlex::Rails::Helpers::TimeZoneOptionsForSelect)
14
+
15
+ def initialize(*modifiers, name: nil, id: nil, selected: nil, priority_zones: nil,
16
+ model: ActiveSupport::TimeZone, placeholder: nil, include_blank: false,
17
+ error: false, disabled: false, required: false, full_width: true, **attributes)
18
+ @modifiers = normalize_modifiers(modifiers)
19
+ @name = name
20
+ @id = id
21
+ @selected = selected
22
+ @priority_zones = priority_zones
23
+ @zone_model = model
24
+ @placeholder = placeholder
25
+ @include_blank = include_blank
26
+ @error = error
27
+ @disabled = disabled
28
+ @required = required
29
+ @full_width = full_width
30
+ @attributes = attributes
31
+ super()
32
+ end
33
+
34
+ def view_template
35
+ render DaisyUI::Select.new(*daisy_modifiers, **select_attributes) do |el|
36
+ render_placeholder(el) if @placeholder || @include_blank
37
+ el.raw(el.safe(time_zone_options_for_select(@selected, @priority_zones, @zone_model)))
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def select_attributes
44
+ data = { controller: "tz" }.merge(@attributes[:data] || {})
45
+ binding_attributes(data:).except(:value)
46
+ end
47
+
48
+ def render_placeholder(el)
49
+ el.option(value: "", disabled: true, selected: @selected.blank?) { @placeholder.to_s }
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ # A model-bound toggle switch, delegating to DaisyUI::Toggle. Emits a hidden
5
+ # field carrying the unchecked value so an unchecked box still submits (the
6
+ # Rails checkbox convention).
7
+ class Toggle < Phlex::HTML
8
+ include PhlexForms::DelegatedField
9
+
10
+ def initialize(*modifiers, name: nil, id: nil, value: "1", unchecked_value: "0",
11
+ checked: false, error: false, disabled: false, required: false, **attributes)
12
+ @modifiers = normalize_modifiers(modifiers)
13
+ @name = name
14
+ @id = id
15
+ @value = value
16
+ @unchecked_value = unchecked_value
17
+ @checked = checked
18
+ @error = error
19
+ @disabled = disabled
20
+ @required = required
21
+ @full_width = false
22
+ @attributes = attributes
23
+ super()
24
+ end
25
+
26
+ def view_template
27
+ input(type: "hidden", name: @name, value: @unchecked_value) if @unchecked_value && @name
28
+ render DaisyUI::Toggle.new(*daisy_modifiers, value: @value, **checkbox_attributes)
29
+ end
30
+
31
+ private
32
+
33
+ def checkbox_attributes
34
+ attrs = binding_attributes
35
+ attrs[:checked] = true if @checked
36
+ attrs
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Validations
5
+ # Introspects an ActiveModel class (or instance) and translates
6
+ # each attribute's validators into a `data:` hash ready to merge
7
+ # into a Phlex element. Phlex handles the `data-` prefixing and
8
+ # underscore-to-hyphen conversion at render time.
9
+ #
10
+ # Sticks to validators whose semantics are reproducible client
11
+ # side. Skips anything with `:if` / `:unless` / `:on` because
12
+ # those need server context the browser doesn't have. Server
13
+ # validation remains authoritative — the client side just
14
+ # shortens the loop for the common cases.
15
+ class Introspector
16
+ CONTROLLER_PREFIX = "forms--validations"
17
+
18
+ # Validators we know how to mirror. Keys are the short class
19
+ # name (without namespace), values are the controller suffix
20
+ # appended to CONTROLLER_PREFIX. Matching on the short name
21
+ # picks up both `ActiveModel::Validations::*` (form objects)
22
+ # and `ActiveRecord::Validations::*` (AR models inherit a
23
+ # parallel set of subclasses for uniqueness/etc.) without
24
+ # listing both namespaces.
25
+ SUPPORTED = {
26
+ "PresenceValidator" => "presence",
27
+ "LengthValidator" => "length",
28
+ "FormatValidator" => "format",
29
+ "NumericalityValidator" => "numericality",
30
+ "InclusionValidator" => "inclusion",
31
+ "ExclusionValidator" => "exclusion",
32
+ "ConfirmationValidator" => "confirmation",
33
+ "AcceptanceValidator" => "acceptance"
34
+ }.freeze
35
+
36
+ LENGTH_KEYS = %i[maximum minimum is].freeze
37
+
38
+ NUMERICALITY_KEYS = %i[
39
+ greater_than greater_than_or_equal_to
40
+ less_than less_than_or_equal_to
41
+ equal_to other_than
42
+ only_integer odd even
43
+ ].freeze
44
+
45
+ # Convenience: returns a null introspector for nil models so
46
+ # callers don't need to guard.
47
+ def self.for(model_or_class)
48
+ return Null.new if model_or_class.nil?
49
+
50
+ klass = model_or_class.is_a?(Class) ? model_or_class : model_or_class.class
51
+ return Null.new unless klass.respond_to?(:validators_on)
52
+
53
+ new(klass)
54
+ end
55
+
56
+ def initialize(model_class)
57
+ @model_class = model_class
58
+ end
59
+
60
+ # Returns a hash of the shape:
61
+ # {
62
+ # controller: "forms--validations--presence forms--validations--length",
63
+ # forms__validations__presence_required_value: "true",
64
+ # forms__validations__length_maximum_value: "60",
65
+ # }
66
+ #
67
+ # Returns {} when no supported validators are present.
68
+ # The double-underscore key encoding survives Phlex/Rails'
69
+ # underscore-to-hyphen rewrite: `__` → `-` (deliberate).
70
+ def data_attributes_for(attribute)
71
+ validators = applicable_validators(attribute)
72
+ return {} if validators.empty?
73
+
74
+ controllers = []
75
+ attrs = {}
76
+
77
+ validators.each do |validator|
78
+ suffix = SUPPORTED[validator.class.name.split("::").last]
79
+ next unless suffix
80
+
81
+ controllers << "#{CONTROLLER_PREFIX}--#{suffix}"
82
+ value_attributes(suffix, validator, attribute).each do |key, value|
83
+ attrs[data_key(suffix, key)] = value
84
+ end
85
+ end
86
+
87
+ return {} if controllers.empty?
88
+
89
+ { controller: controllers.uniq.join(" ") }.merge(attrs)
90
+ end
91
+
92
+ private
93
+
94
+ attr_reader :model_class
95
+
96
+ # Phlex turns underscores in `data:` hash keys into hyphens
97
+ # in the rendered HTML. To produce a key like
98
+ # `data-forms--validations--length-maximum-value` from a
99
+ # Ruby symbol we need every "-" represented as "__" in the
100
+ # symbol. That's what this method builds.
101
+ def data_key(suffix, key)
102
+ prefix = CONTROLLER_PREFIX.tr("-", "_")
103
+ :"#{prefix}__#{suffix}_#{key}_value"
104
+ end
105
+
106
+ def applicable_validators(attribute)
107
+ return [] unless model_class.respond_to?(:validators_on)
108
+
109
+ model_class.validators_on(attribute).reject { |v| conditional?(v) }
110
+ end
111
+
112
+ def conditional?(validator)
113
+ opts = validator.options
114
+ opts[:if].present? || opts[:unless].present? || opts[:on].present?
115
+ end
116
+
117
+ def value_attributes(suffix, validator, attribute)
118
+ case suffix
119
+ when "presence" then presence_attrs
120
+ when "length" then length_attrs(validator)
121
+ when "format" then format_attrs(validator)
122
+ when "numericality" then numericality_attrs(validator)
123
+ when "inclusion", "exclusion" then collection_attrs(validator)
124
+ when "confirmation" then confirmation_attrs(attribute)
125
+ when "acceptance" then acceptance_attrs(validator)
126
+ else {}
127
+ end
128
+ end
129
+
130
+ def presence_attrs
131
+ { required: "true" }
132
+ end
133
+
134
+ def length_attrs(validator)
135
+ opts = validator.options
136
+ attrs = {}
137
+
138
+ LENGTH_KEYS.each do |key|
139
+ attrs[key] = opts[key].to_s if opts.key?(key)
140
+ end
141
+
142
+ if opts.key?(:in) || opts.key?(:within)
143
+ range = opts[:in] || opts[:within]
144
+ attrs[:minimum] = range.min.to_s
145
+ attrs[:maximum] = range.max.to_s
146
+ end
147
+
148
+ attrs[:allow_blank] = "true" if opts[:allow_blank]
149
+ attrs[:allow_nil] = "true" if opts[:allow_nil]
150
+ attrs
151
+ end
152
+
153
+ def format_attrs(validator)
154
+ opts = validator.options
155
+ regex = opts[:with]
156
+ return {} unless regex.is_a?(Regexp)
157
+
158
+ attrs = { pattern: js_regex_source(regex) }
159
+ flags = js_regex_flags(regex)
160
+ attrs[:flags] = flags unless flags.empty?
161
+ attrs[:allow_blank] = "true" if opts[:allow_blank]
162
+ attrs
163
+ end
164
+
165
+ # JS regex syntax is mostly compatible with Ruby's, but two
166
+ # gotchas matter for the validators we'll be wiring up:
167
+ # - Ruby `\A` / `\z` anchors → JS `^` / `$`
168
+ # - Inline modifiers like `(?-mix:...)` need stripping
169
+ def js_regex_source(regex)
170
+ src = regex.source
171
+ src = src.gsub(/\A\(\?[-mix]+:(.+)\)\z/, '\1') # strip wrapper modifiers
172
+ src.gsub("\\A", "^").gsub("\\z", "$").gsub("\\Z", "$")
173
+ end
174
+
175
+ def js_regex_flags(regex)
176
+ flags = []
177
+ flags << "i" if regex.options.anybits?(Regexp::IGNORECASE)
178
+ flags << "m" if regex.options.anybits?(Regexp::MULTILINE)
179
+ flags.join
180
+ end
181
+
182
+ def numericality_attrs(validator)
183
+ opts = validator.options
184
+ attrs = {}
185
+
186
+ NUMERICALITY_KEYS.each do |key|
187
+ next unless opts.key?(key)
188
+
189
+ value = opts[key]
190
+ attrs[key] = value.is_a?(TrueClass) ? "true" : value.to_s
191
+ end
192
+
193
+ attrs[:allow_nil] = "true" if opts[:allow_nil]
194
+ attrs[:allow_blank] = "true" if opts[:allow_blank]
195
+ attrs
196
+ end
197
+
198
+ def collection_attrs(validator)
199
+ list = validator.options[:in]
200
+ return {} unless list.respond_to?(:to_a)
201
+
202
+ attrs = { in: list.to_a.to_json }
203
+ attrs[:allow_blank] = "true" if validator.options[:allow_blank]
204
+ attrs
205
+ end
206
+
207
+ def confirmation_attrs(attribute)
208
+ { match: "#{attribute}_confirmation" }
209
+ end
210
+
211
+ def acceptance_attrs(validator)
212
+ accept = validator.options[:accept] || %w[1 true]
213
+ { accept: Array(accept).map(&:to_s).to_json }
214
+ end
215
+
216
+ # No-op introspector returned for nil / non-AR models.
217
+ class Null
218
+ def data_attributes_for(_attribute)
219
+ {}
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Forms
4
+ module Validations
5
+ # Accepts an inline rules hash and produces the same data-* hash
6
+ # shape that `Introspector#data_attributes_for` returns. Used when
7
+ # a caller wants client-side validation on a form not backed by
8
+ # an ActiveModel object (e.g. plain form objects, marketplace
9
+ # submissions that bypass the model in test setup, etc.).
10
+ #
11
+ # Supported shape:
12
+ #
13
+ # ManualRules.new(
14
+ # length: { maximum: 60, minimum: 3 },
15
+ # presence: true,
16
+ # format: { with: /\A[a-z]+\z/i },
17
+ # numericality: { greater_than_or_equal_to: 0 },
18
+ # inclusion: { in: %w[a b c] },
19
+ # exclusion: { in: %w[admin] },
20
+ # confirmation: true,
21
+ # acceptance: true,
22
+ # )
23
+ class ManualRules
24
+ def initialize(rules)
25
+ @rules = rules
26
+ end
27
+
28
+ def data_attributes
29
+ # Build a tiny ad-hoc model whose validators reproduce the
30
+ # rules, then delegate to the Introspector. Saves us
31
+ # reimplementing the per-validator → data-attr conversion in
32
+ # two places.
33
+ klass = build_adhoc_model
34
+ Introspector.new(klass).data_attributes_for(:value)
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :rules
40
+
41
+ def build_adhoc_model
42
+ captured_rules = rules
43
+ Class.new do
44
+ include ActiveModel::Model
45
+ include ActiveModel::Validations
46
+
47
+ attr_accessor :value, :value_confirmation
48
+
49
+ captured_rules.each do |key, opts|
50
+ case key
51
+ when :presence
52
+ validates :value, presence: true if opts
53
+ when :length
54
+ validates :value, length: opts
55
+ when :format
56
+ validates :value, format: opts
57
+ when :numericality
58
+ validates :value, numericality: opts
59
+ when :inclusion
60
+ validates :value, inclusion: opts
61
+ when :exclusion
62
+ validates :value, exclusion: opts
63
+ when :confirmation
64
+ validates :value, confirmation: true if opts
65
+ when :acceptance
66
+ validates :value, acceptance: opts.is_a?(Hash) ? opts : { accept: %w[1 true] }
67
+ end
68
+ end
69
+
70
+ def self.name
71
+ "Forms::Validations::ManualRules::Adhoc"
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end