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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +170 -0
- data/app/javascript/phlex_forms/controllers/validations/acceptance_controller.js +23 -0
- data/app/javascript/phlex_forms/controllers/validations/base_controller.js +166 -0
- data/app/javascript/phlex_forms/controllers/validations/confirmation_controller.js +27 -0
- data/app/javascript/phlex_forms/controllers/validations/exclusion_controller.js +19 -0
- data/app/javascript/phlex_forms/controllers/validations/form_controller.js +43 -0
- data/app/javascript/phlex_forms/controllers/validations/format_controller.js +23 -0
- data/app/javascript/phlex_forms/controllers/validations/inclusion_controller.js +19 -0
- data/app/javascript/phlex_forms/controllers/validations/length_controller.js +95 -0
- data/app/javascript/phlex_forms/controllers/validations/numericality_controller.js +59 -0
- data/app/javascript/phlex_forms/controllers/validations/presence_controller.js +14 -0
- data/app/javascript/phlex_forms/i18n.js +40 -0
- data/app/javascript/phlex_forms/messages.js +100 -0
- data/config/importmap.rb +21 -0
- data/config/locales/de.yml +12 -0
- data/config/locales/en.yml +14 -0
- data/config/locales/sv.yml +12 -0
- data/config/rubocop.yml +20 -0
- data/lib/forms/checkbox.rb +39 -0
- data/lib/forms/choices_select.rb +132 -0
- data/lib/forms/collection_check_box.rb +26 -0
- data/lib/forms/collection_check_box_builder.rb +26 -0
- data/lib/forms/collection_label.rb +20 -0
- data/lib/forms/email_field.rb +11 -0
- data/lib/forms/field.rb +222 -0
- data/lib/forms/field_error.rb +26 -0
- data/lib/forms/field_hint.rb +24 -0
- data/lib/forms/fields_for_builder.rb +58 -0
- data/lib/forms/file_input.rb +29 -0
- data/lib/forms/form.rb +225 -0
- data/lib/forms/form_control.rb +42 -0
- data/lib/forms/input.rb +35 -0
- data/lib/forms/label.rb +32 -0
- data/lib/forms/password_field.rb +11 -0
- data/lib/forms/radio.rb +29 -0
- data/lib/forms/range.rb +33 -0
- data/lib/forms/rich_textarea.rb +43 -0
- data/lib/forms/select.rb +73 -0
- data/lib/forms/submit.rb +62 -0
- data/lib/forms/textarea.rb +27 -0
- data/lib/forms/time_zone_select.rb +52 -0
- data/lib/forms/toggle.rb +39 -0
- data/lib/forms/validations/introspector.rb +224 -0
- data/lib/forms/validations/manual_rules.rb +77 -0
- data/lib/forms/wrapped_input.rb +67 -0
- data/lib/phlex-forms.rb +4 -0
- data/lib/phlex_forms/builder.rb +153 -0
- data/lib/phlex_forms/class_merge.rb +56 -0
- data/lib/phlex_forms/configuration.rb +49 -0
- data/lib/phlex_forms/delegated_field.rb +51 -0
- data/lib/phlex_forms/engine.rb +35 -0
- data/lib/phlex_forms/inline_icons.rb +36 -0
- data/lib/phlex_forms/rubocop.rb +14 -0
- data/lib/phlex_forms/version.rb +5 -0
- data/lib/phlex_forms.rb +80 -0
- data/lib/rubocop/phlex_forms/cop/legacy_form_method.rb +103 -0
- data/lib/rubocop/phlex_forms/cop/raw_form.rb +51 -0
- 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
|
data/lib/forms/radio.rb
ADDED
|
@@ -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
|
data/lib/forms/range.rb
ADDED
|
@@ -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
|
data/lib/forms/select.rb
ADDED
|
@@ -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
|
data/lib/forms/submit.rb
ADDED
|
@@ -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
|
data/lib/forms/toggle.rb
ADDED
|
@@ -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
|