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
data/lib/forms/field.rb
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Per-field context: knows a field's name, its scope, the bound model, and the
|
|
4
|
+
# error set. Builds the concrete leaf components (input/textarea/select/...) with
|
|
5
|
+
# name/id/value/error wired in, and derives the label text and required flag.
|
|
6
|
+
#
|
|
7
|
+
# Not usually built directly — `Form#field(:name)` / the builder return one.
|
|
8
|
+
module Forms
|
|
9
|
+
class Field
|
|
10
|
+
attr_reader :name, :model, :scope, :errors
|
|
11
|
+
|
|
12
|
+
def initialize(name:, model:, scope:, errors:, form:)
|
|
13
|
+
@name = name
|
|
14
|
+
@model = model
|
|
15
|
+
@scope = scope
|
|
16
|
+
@errors = errors
|
|
17
|
+
@form = form
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# --- leaf component builders (return component instances to render) ---
|
|
21
|
+
|
|
22
|
+
def input(*modifiers, type: :text, **)
|
|
23
|
+
Forms::Input.new(*modifiers, type:, **field_attributes, **)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def textarea(*modifiers, **)
|
|
27
|
+
Forms::Textarea.new(*modifiers, **field_attributes, **)
|
|
28
|
+
end
|
|
29
|
+
alias text_area textarea
|
|
30
|
+
|
|
31
|
+
def rich_textarea(*modifiers, **)
|
|
32
|
+
Forms::RichTextarea.new(*modifiers, name: field_name, id: field_id, value: field_value, **)
|
|
33
|
+
end
|
|
34
|
+
alias rich_text_area rich_textarea
|
|
35
|
+
|
|
36
|
+
def hidden(**)
|
|
37
|
+
Forms::Input.new(type: :hidden, **field_attributes, **)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# daisyui v5 "icon/text inside the field" wrapper. The block renders the
|
|
41
|
+
# leading content (icon, prefix); the bare input is wired to this field.
|
|
42
|
+
def wrapped_input(*modifiers, type: :text, **, &)
|
|
43
|
+
Forms::WrappedInput.new(*modifiers, type:, **field_attributes.except(:error), error: invalid?, **, &)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def file(*modifiers, **)
|
|
47
|
+
Forms::FileInput.new(*modifiers, **field_attributes.except(:value), **)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def checkbox(*modifiers, **)
|
|
51
|
+
Forms::Checkbox.new(*modifiers, checked: field_value, **field_attributes.except(:value), **)
|
|
52
|
+
end
|
|
53
|
+
alias check_box checkbox
|
|
54
|
+
|
|
55
|
+
def toggle(*modifiers, **)
|
|
56
|
+
Forms::Toggle.new(*modifiers, checked: field_value, **field_attributes.except(:value), **)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def radio(value, *modifiers, **options)
|
|
60
|
+
Forms::Radio.new(
|
|
61
|
+
*modifiers,
|
|
62
|
+
value:,
|
|
63
|
+
checked: field_value == value,
|
|
64
|
+
**field_attributes.merge(options).merge(id: "#{field_id}_#{value}")
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
alias radio_button radio
|
|
68
|
+
|
|
69
|
+
def select(choices = nil, **options)
|
|
70
|
+
Forms::Select.new(choices:, selected: field_value, **select_options(options))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Enhanced select: choices.js-backed when searchable, native otherwise.
|
|
74
|
+
def choices_select(choices = nil, *modifiers, **options)
|
|
75
|
+
searchable = options.delete(:searchable) { false }
|
|
76
|
+
opts = select_options(options)
|
|
77
|
+
if searchable
|
|
78
|
+
Forms::ChoicesSelect.new(*modifiers, choices:, selected: field_value, searchable: true, **opts)
|
|
79
|
+
else
|
|
80
|
+
Forms::Select.new(*modifiers, choices:, selected: field_value, **opts)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def label(text = nil, *modifiers, **, &block)
|
|
85
|
+
Forms::Label.new(*modifiers, text: text || (block ? nil : field_label), for: field_id, **, &block)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def control(label: nil, hint: nil, required: false, **, &)
|
|
89
|
+
Forms::FormControl.new(label:, hint:, error: field_error_message, for: field_id, required:, **, &)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# --- derived metadata ---
|
|
93
|
+
|
|
94
|
+
# Humanized label text: the model's human_attribute_name when available.
|
|
95
|
+
def field_label
|
|
96
|
+
if @model.respond_to?(:class) && @model.class.respond_to?(:human_attribute_name)
|
|
97
|
+
@model.class.human_attribute_name(@name)
|
|
98
|
+
else
|
|
99
|
+
@name.to_s.tr("_", " ").capitalize
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Inferred from the model's presence validators (ActiveModel). False when the
|
|
104
|
+
# model doesn't expose validators.
|
|
105
|
+
def required?
|
|
106
|
+
return false unless @model.respond_to?(:class) && @model.class.respond_to?(:validators_on)
|
|
107
|
+
|
|
108
|
+
@model.class.validators_on(@name).any? do |v|
|
|
109
|
+
v.is_a?(ActiveModel::Validations::PresenceValidator) && !conditional?(v)
|
|
110
|
+
end
|
|
111
|
+
rescue StandardError
|
|
112
|
+
false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def invalid?
|
|
116
|
+
@errors&.include?(@name)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def field_name
|
|
120
|
+
@scope ? "#{@scope}[#{@name}]" : @name.to_s
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def field_id
|
|
124
|
+
if @scope
|
|
125
|
+
"#{@scope.tr('[', '_').delete(']')}_#{@name}"
|
|
126
|
+
else
|
|
127
|
+
@name.to_s
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Ransack-safe getter: dispatches through method_missing consumers (e.g.
|
|
132
|
+
# Ransack::Search predicate getters) but only swallows the direct dispatch miss.
|
|
133
|
+
def field_value
|
|
134
|
+
return nil unless @model
|
|
135
|
+
|
|
136
|
+
@model.public_send(@name)
|
|
137
|
+
rescue NoMethodError => e
|
|
138
|
+
raise unless e.receiver.equal?(@model) && e.name == @name
|
|
139
|
+
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# --- client-side validation wiring (pairs with Forms::Validations) ---
|
|
144
|
+
|
|
145
|
+
# Merge a per-call `validate:` override into the field's option hash. Strips
|
|
146
|
+
# `:validate` and, when rules apply, merges the Stimulus data into `data:`.
|
|
147
|
+
# Falls back to the form-level introspector when `:validate` is absent.
|
|
148
|
+
#
|
|
149
|
+
# validate: false → opt this field out
|
|
150
|
+
# validate: true → the form-level introspector
|
|
151
|
+
# validate: { length: {…} } → explicit inline rules
|
|
152
|
+
def apply_validations(options)
|
|
153
|
+
return consume_validate(options) if options.key?(:validate)
|
|
154
|
+
|
|
155
|
+
data = form_introspector.data_attributes_for(@name)
|
|
156
|
+
return options if data.empty?
|
|
157
|
+
|
|
158
|
+
options.merge(data: merge_data(options[:data], data))
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def consume_validate(options)
|
|
164
|
+
return options unless options.key?(:validate)
|
|
165
|
+
|
|
166
|
+
override = options.delete(:validate)
|
|
167
|
+
data = validation_data_for(override)
|
|
168
|
+
return options if data.empty?
|
|
169
|
+
|
|
170
|
+
options.merge(data: merge_data(options[:data], data))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def validation_data_for(override)
|
|
174
|
+
case override
|
|
175
|
+
when true then form_introspector.data_attributes_for(@name)
|
|
176
|
+
when Hash then Forms::Validations::ManualRules.new(override).data_attributes
|
|
177
|
+
else {} # false / nil / anything else → no client-side validation
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def form_introspector
|
|
182
|
+
if @form.respond_to?(:validations_introspector)
|
|
183
|
+
@form.validations_introspector
|
|
184
|
+
else
|
|
185
|
+
Forms::Validations::Introspector::Null.new
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def merge_data(existing, additions)
|
|
190
|
+
existing = (existing || {}).dup
|
|
191
|
+
additions.each do |key, value|
|
|
192
|
+
if key == :controller
|
|
193
|
+
existing[:controller] = [existing[:controller], value].compact.reject { |s| s.to_s.empty? }.join(" ")
|
|
194
|
+
else
|
|
195
|
+
existing[key] = value
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
existing
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def conditional?(validator)
|
|
202
|
+
validator.options.key?(:if) || validator.options.key?(:unless) || validator.options.key?(:on)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def field_attributes
|
|
206
|
+
{ name: field_name, id: field_id, value: field_value, error: invalid? }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def field_error_message
|
|
210
|
+
@errors&.full_messages_for(@name)&.first if invalid?
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def select_options(options)
|
|
214
|
+
include_blank = options.delete(:include_blank)
|
|
215
|
+
prompt = options.delete(:prompt)
|
|
216
|
+
opts = field_attributes.merge(options)
|
|
217
|
+
opts[:include_blank] = include_blank unless include_blank.nil?
|
|
218
|
+
opts[:prompt] = prompt unless prompt.nil?
|
|
219
|
+
opts
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
# Inline validation error shown beneath a field. Uses the same visual treatment
|
|
5
|
+
# (`text-error text-sm`) the client-side validation controllers apply, so a
|
|
6
|
+
# server-rendered error and a live client-rendered one look identical.
|
|
7
|
+
class FieldError < Phlex::HTML
|
|
8
|
+
def initialize(message: nil, **options)
|
|
9
|
+
@message = message
|
|
10
|
+
@options = options
|
|
11
|
+
super()
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def view_template
|
|
15
|
+
return unless @message
|
|
16
|
+
|
|
17
|
+
p(class: classes) { @message }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def classes
|
|
23
|
+
PhlexForms::ClassMerge.merge("text-error text-sm mt-1", @options[:class])
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
# Inline hint/help text shown beneath a field.
|
|
5
|
+
class FieldHint < Phlex::HTML
|
|
6
|
+
def initialize(text: nil, **options)
|
|
7
|
+
@text = text
|
|
8
|
+
@options = options
|
|
9
|
+
super()
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def view_template
|
|
13
|
+
return unless @text
|
|
14
|
+
|
|
15
|
+
p(class: classes) { @text }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def classes
|
|
21
|
+
PhlexForms::ClassMerge.merge("text-base-content/60 text-sm mt-1", @options[:class])
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
# Yielded inside Form#fields_for for nested attributes. Reuses the shared
|
|
5
|
+
# Builder API (f.field, f.Input, ...) against a nested scope, rendering through
|
|
6
|
+
# the parent form.
|
|
7
|
+
class FieldsForBuilder
|
|
8
|
+
include PhlexForms::Builder
|
|
9
|
+
|
|
10
|
+
attr_reader :model, :scope, :errors, :parent_form
|
|
11
|
+
|
|
12
|
+
def initialize(model:, scope:, errors:, parent_form:)
|
|
13
|
+
@model = model
|
|
14
|
+
@scope = scope
|
|
15
|
+
@errors = errors
|
|
16
|
+
@parent_form = parent_form
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The Builder mixin renders through `render`; delegate to the parent form.
|
|
20
|
+
def render(...)
|
|
21
|
+
@parent_form.render(...)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def field_object(name)
|
|
25
|
+
Forms::Field.new(name:, model: @model, scope: @scope, errors: @errors, form: @parent_form)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Nested fields_for (single association or has_many collection).
|
|
29
|
+
def fields_for(association_name, model = nil, &)
|
|
30
|
+
return unless block_given?
|
|
31
|
+
|
|
32
|
+
associated = model || (@model.public_send(association_name) if @model.respond_to?(association_name))
|
|
33
|
+
base_scope = "#{@scope}[#{association_name}_attributes]"
|
|
34
|
+
|
|
35
|
+
if associated.respond_to?(:each_with_index)
|
|
36
|
+
associated.each_with_index do |item, index|
|
|
37
|
+
yield build_nested("#{base_scope}[#{index}]", item)
|
|
38
|
+
end
|
|
39
|
+
else
|
|
40
|
+
yield build_nested(base_scope, associated)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def field_name(name) = "#{@scope}[#{name}]"
|
|
45
|
+
def field_id(name) = "#{@scope.tr('[', '_').delete(']')}_#{name}"
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def build_nested(scope, item)
|
|
50
|
+
self.class.new(
|
|
51
|
+
model: item,
|
|
52
|
+
scope:,
|
|
53
|
+
errors: (item.errors if item.respond_to?(:errors)),
|
|
54
|
+
parent_form: @parent_form
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
# A model-bound `<input type="file">`, delegating to DaisyUI::FileInput.
|
|
5
|
+
class FileInput < Phlex::HTML
|
|
6
|
+
include PhlexForms::DelegatedField
|
|
7
|
+
|
|
8
|
+
def initialize(*modifiers, name: nil, id: nil, multiple: false, accept: nil,
|
|
9
|
+
error: false, disabled: false, required: false, full_width: true, **attributes)
|
|
10
|
+
@modifiers = normalize_modifiers(modifiers)
|
|
11
|
+
@name = name
|
|
12
|
+
@id = id
|
|
13
|
+
@multiple = multiple
|
|
14
|
+
@accept = accept
|
|
15
|
+
@error = error
|
|
16
|
+
@disabled = disabled
|
|
17
|
+
@required = required
|
|
18
|
+
@full_width = full_width
|
|
19
|
+
@attributes = attributes
|
|
20
|
+
super()
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def view_template
|
|
24
|
+
attrs = binding_attributes(accept: @accept)
|
|
25
|
+
attrs[:multiple] = true if @multiple
|
|
26
|
+
render DaisyUI::FileInput.new(*daisy_modifiers, **attrs)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/forms/form.rb
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The model-bound form. Yields itself as the builder:
|
|
4
|
+
#
|
|
5
|
+
# Form(model: @user) do |f|
|
|
6
|
+
# f.field :email
|
|
7
|
+
# f.field :role, as: :select, choices: roles
|
|
8
|
+
# f.submit
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# Derives scope/url/method from the model (including the polymorphic array form
|
|
12
|
+
# `model: [parent, child]`), emits the CSRF + method-override hidden fields, and
|
|
13
|
+
# always sets multipart encoding for non-GET forms so file inputs never silently
|
|
14
|
+
# fail to upload.
|
|
15
|
+
module Forms
|
|
16
|
+
class Form < Phlex::HTML
|
|
17
|
+
include Phlex::Rails::Helpers::FormAuthenticityToken if defined?(Phlex::Rails::Helpers::FormAuthenticityToken)
|
|
18
|
+
include PhlexForms::Builder
|
|
19
|
+
|
|
20
|
+
attr_reader :model, :scope, :url, :method, :errors, :validate
|
|
21
|
+
|
|
22
|
+
def initialize(*modifiers, model: nil, scope: nil, url: nil, method: nil, validate: false, **options)
|
|
23
|
+
super()
|
|
24
|
+
@base_modifiers = modifiers
|
|
25
|
+
@options = options
|
|
26
|
+
@model = record_from(model)
|
|
27
|
+
@scope = scope&.to_s || derive_scope(@model)
|
|
28
|
+
@url = url || derive_url(model)
|
|
29
|
+
@method = method || derive_method(@model)
|
|
30
|
+
@errors = (@model.errors if @model.respond_to?(:errors))
|
|
31
|
+
@validate = validate
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Introspector for the bound model when client-side validation is enabled, or
|
|
35
|
+
# a no-op Null otherwise. Callers can always call #data_attributes_for(attr).
|
|
36
|
+
def validations_introspector
|
|
37
|
+
@validations_introspector ||=
|
|
38
|
+
if @validate
|
|
39
|
+
Forms::Validations::Introspector.for(@model)
|
|
40
|
+
else
|
|
41
|
+
Forms::Validations::Introspector::Null.new
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def view_template(&)
|
|
46
|
+
form(action: @url, accept_charset: "UTF-8", method: form_method, **form_attributes) do
|
|
47
|
+
authenticity_token_field unless @method&.to_sym == :get
|
|
48
|
+
method_field if @method && %i[get post].exclude?(@method.to_sym)
|
|
49
|
+
yield self if block_given?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Return a Forms::Field for a name (used by the Builder mixin).
|
|
54
|
+
def field_object(name)
|
|
55
|
+
Forms::Field.new(name:, model: @model, scope: @scope, errors: @errors, form: self)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def submit(*, **, &)
|
|
59
|
+
render Forms::Submit.new(*, model: @model, **, &)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def rich_textarea(name, *modifiers, **)
|
|
63
|
+
render field_object(name).rich_textarea(*modifiers, **)
|
|
64
|
+
end
|
|
65
|
+
alias rich_text_area rich_textarea
|
|
66
|
+
|
|
67
|
+
def time_zone_select(name, *modifiers, selected: nil, **)
|
|
68
|
+
render Forms::TimeZoneSelect.new(
|
|
69
|
+
*modifiers,
|
|
70
|
+
name: field_name(name),
|
|
71
|
+
id: field_id(name),
|
|
72
|
+
selected: selected || @model&.public_send(name),
|
|
73
|
+
**
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Nested attributes. Yields a FieldsForBuilder per association (single) or per
|
|
78
|
+
# item (has_many), with the correctly-indexed nested scope.
|
|
79
|
+
def fields_for(association_name, model = nil, &)
|
|
80
|
+
return unless block_given?
|
|
81
|
+
|
|
82
|
+
associated = model || (@model.public_send(association_name) if @model.respond_to?(association_name))
|
|
83
|
+
attributes_key = "#{association_name}_attributes"
|
|
84
|
+
base_scope = @scope ? "#{@scope}[#{attributes_key}]" : attributes_key
|
|
85
|
+
|
|
86
|
+
if associated.respond_to?(:each_with_index)
|
|
87
|
+
associated.each_with_index do |item, index|
|
|
88
|
+
yield build_fields_for("#{base_scope}[#{index}]", item)
|
|
89
|
+
end
|
|
90
|
+
else
|
|
91
|
+
yield build_fields_for(base_scope, associated)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Rails-style collection_check_boxes. Emits a hidden field so an empty
|
|
96
|
+
# selection still submits, then yields a builder per collection item.
|
|
97
|
+
def collection_check_boxes(name, collection, value_method, text_method, &)
|
|
98
|
+
return unless block_given?
|
|
99
|
+
|
|
100
|
+
input(type: "hidden", name: "#{field_name(name)}[]", value: "")
|
|
101
|
+
current = Array(@model&.public_send(name))
|
|
102
|
+
current_ids = current.map { |v| v.respond_to?(value_method) ? v.public_send(value_method) : v }
|
|
103
|
+
|
|
104
|
+
collection.each do |item|
|
|
105
|
+
item_value = item.public_send(value_method)
|
|
106
|
+
item_text = text_method.is_a?(Proc) ? text_method.call(item) : item.public_send(text_method)
|
|
107
|
+
yield Forms::CollectionCheckBoxBuilder.new(
|
|
108
|
+
object: item, value: item_value, text: item_text,
|
|
109
|
+
checked: current_ids.include?(item_value),
|
|
110
|
+
name: "#{field_name(name)}[]", id: "#{field_id(name)}_#{item_value}"
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Rails-style collection_select over an enumerable of records.
|
|
116
|
+
def collection_select(name, collection, value_method, text_method, options = {}, html_options = {})
|
|
117
|
+
choices = collection.map do |item|
|
|
118
|
+
text = text_method.is_a?(Proc) ? text_method.call(item) : item.public_send(text_method)
|
|
119
|
+
[text, item.public_send(value_method)]
|
|
120
|
+
end
|
|
121
|
+
choices = [[options[:prompt], ""]] + choices if options[:prompt]
|
|
122
|
+
render field_object(name).select(choices, **options.except(:prompt), **html_options)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Public name/id/value helpers for external components mirroring the Rails API.
|
|
126
|
+
def field_name(name) = @scope ? "#{@scope}[#{name}]" : name.to_s
|
|
127
|
+
def field_id(name) = @scope ? "#{@scope}_#{name}" : name.to_s
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def build_fields_for(scope, item)
|
|
132
|
+
Forms::FieldsForBuilder.new(
|
|
133
|
+
model: item,
|
|
134
|
+
scope:,
|
|
135
|
+
errors: (item.errors if item.respond_to?(:errors)),
|
|
136
|
+
parent_form: self
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# For a polymorphic array model, the record is the last element.
|
|
141
|
+
def record_from(model)
|
|
142
|
+
model.is_a?(Array) ? model.last : model
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def form_method
|
|
146
|
+
%i[get post].include?(@method.to_sym) ? @method : :post
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def form_attributes
|
|
150
|
+
attrs = @options.except(:class, :local, :multipart, :turbo_frame, :id, :data)
|
|
151
|
+
attrs[:id] = @options[:id] if @options[:id]
|
|
152
|
+
if (classes = form_classes)
|
|
153
|
+
attrs[:class] = classes
|
|
154
|
+
end
|
|
155
|
+
attrs[:data] = (@options[:data] || {}).dup
|
|
156
|
+
attrs[:data][:turbo] = "false" if @options[:local] == true
|
|
157
|
+
attrs[:data][:turbo_frame] = @options[:turbo_frame] if @options[:turbo_frame]
|
|
158
|
+
apply_validation_coordinator(attrs) if @validate
|
|
159
|
+
attrs[:data].transform_values! { |v| v == false ? "false" : v }
|
|
160
|
+
attrs[:enctype] = "multipart/form-data" unless @method&.to_sym == :get
|
|
161
|
+
attrs
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# When client-side validation is on, attach the form-level coordinator
|
|
165
|
+
# controller (submit interceptor) and turn OFF the native browser validation
|
|
166
|
+
# UI (novalidate) — the Stimulus layer owns error display.
|
|
167
|
+
def apply_validation_coordinator(attrs)
|
|
168
|
+
existing = attrs[:data][:controller].to_s
|
|
169
|
+
coordinator = "forms--validations--form"
|
|
170
|
+
attrs[:data][:controller] = [existing, coordinator].reject(&:empty?).join(" ")
|
|
171
|
+
attrs[:novalidate] = true
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def form_classes
|
|
175
|
+
classes = []
|
|
176
|
+
classes << "space-y-4" if @base_modifiers.include?(:spaced)
|
|
177
|
+
classes << "space-y-6" if @base_modifiers.include?(:spacious)
|
|
178
|
+
PhlexForms::ClassMerge.merge(classes.join(" "), @options[:class]).presence
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def authenticity_token_field
|
|
182
|
+
return unless respond_to?(:form_authenticity_token)
|
|
183
|
+
|
|
184
|
+
token = form_authenticity_token
|
|
185
|
+
input(type: "hidden", name: "authenticity_token", value: token) if token
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def method_field
|
|
189
|
+
input(type: "hidden", name: "_method", value: @method)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def derive_scope(record)
|
|
193
|
+
return nil unless record
|
|
194
|
+
|
|
195
|
+
if record.respond_to?(:model_name)
|
|
196
|
+
record.model_name.param_key
|
|
197
|
+
elsif record.is_a?(Symbol) || record.is_a?(String)
|
|
198
|
+
record.to_s
|
|
199
|
+
else
|
|
200
|
+
record.class.name.underscore.tr("/", "_")
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# For an array model, build the url from the whole array (polymorphic nesting);
|
|
205
|
+
# otherwise from the record.
|
|
206
|
+
def derive_url(model)
|
|
207
|
+
return "/" unless model
|
|
208
|
+
|
|
209
|
+
record = record_from(model)
|
|
210
|
+
persisted = record.respond_to?(:persisted?) && record.persisted?
|
|
211
|
+
prefix = url_prefix(model, record)
|
|
212
|
+
persisted ? "#{prefix}/#{record.to_param}" : prefix
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def url_prefix(model, record)
|
|
216
|
+
parents = model.is_a?(Array) ? model[0...-1] : []
|
|
217
|
+
segments = parents.map { |p| "/#{p.class.name.underscore.pluralize}/#{p.to_param}" }
|
|
218
|
+
"#{segments.join}/#{derive_scope(record).pluralize}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def derive_method(record)
|
|
222
|
+
record.respond_to?(:persisted?) && record.persisted? ? :patch : :post
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
# Wraps a field in the daisyui form-control layout: optional label on top, the
|
|
5
|
+
# field (yielded block), then an error (if present) or a hint. This is the
|
|
6
|
+
# workhorse behind the Control-first `f.field` API and the explicit `f.Control`
|
|
7
|
+
# escape hatch.
|
|
8
|
+
class FormControl < Phlex::HTML
|
|
9
|
+
def initialize(*modifiers, label: nil, hint: nil, error: nil, for: nil, required: false, **options)
|
|
10
|
+
@modifiers = modifiers
|
|
11
|
+
@label = label
|
|
12
|
+
@hint = hint
|
|
13
|
+
@error = error
|
|
14
|
+
@field_id = grab(for:)
|
|
15
|
+
@required = required
|
|
16
|
+
@options = options
|
|
17
|
+
super()
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def view_template(&)
|
|
21
|
+
div(class: control_classes, **@options.except(:class)) do
|
|
22
|
+
render Forms::Label.new(text: @label, for: @field_id, required: @required) if @label
|
|
23
|
+
|
|
24
|
+
yield if block_given?
|
|
25
|
+
|
|
26
|
+
if @error
|
|
27
|
+
render Forms::FieldError.new(message: @error)
|
|
28
|
+
elsif @hint
|
|
29
|
+
render Forms::FieldHint.new(text: @hint)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def control_classes
|
|
37
|
+
base = %w[form-control w-full]
|
|
38
|
+
base << "flex flex-row items-center gap-4" if @modifiers.include?(:horizontal)
|
|
39
|
+
PhlexForms::ClassMerge.merge(base.join(" "), @options[:class])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/forms/input.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
# A model-bound text-like input. Thin form-binding layer over DaisyUI::Input:
|
|
5
|
+
# it wires name/id/value/error/required onto the field, then delegates all
|
|
6
|
+
# markup and variant handling to the daisyui gem's component (which stacks
|
|
7
|
+
# positional color/size/style modifiers and passes a block straight through).
|
|
8
|
+
#
|
|
9
|
+
# Forms::Input.new(:primary, :lg, type: "email", name: "user[email]")
|
|
10
|
+
#
|
|
11
|
+
# `w-full` is added by default. For the daisyui v5 "icon/text inside the field"
|
|
12
|
+
# pattern (a <label class="input"> wrapper), use Forms::WrappedInput.
|
|
13
|
+
class Input < Phlex::HTML
|
|
14
|
+
include PhlexForms::DelegatedField
|
|
15
|
+
|
|
16
|
+
def initialize(*modifiers, type: "text", name: nil, id: nil, value: nil,
|
|
17
|
+
error: false, disabled: false, required: false, full_width: true, **attributes)
|
|
18
|
+
@modifiers = normalize_modifiers(modifiers)
|
|
19
|
+
@type = type
|
|
20
|
+
@name = name
|
|
21
|
+
@id = id
|
|
22
|
+
@value = value
|
|
23
|
+
@error = error
|
|
24
|
+
@disabled = disabled
|
|
25
|
+
@required = required
|
|
26
|
+
@full_width = full_width
|
|
27
|
+
@attributes = attributes
|
|
28
|
+
super()
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def view_template(&)
|
|
32
|
+
render DaisyUI::Input.new(*daisy_modifiers, type: @type, value: @value.to_s, **binding_attributes, &)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/forms/label.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
# A standalone form label: `<label for><span class="label">text</span></label>`
|
|
5
|
+
# with an optional required marker. A block, when given, is yielded directly
|
|
6
|
+
# inside the `<label>` so checkboxes/toggles can nest as direct children.
|
|
7
|
+
#
|
|
8
|
+
# For the daisyui v5 "text/icon inside the field" wrapper (<label class="input">
|
|
9
|
+
# {span.label}{input}), use Forms::WrappedInput instead.
|
|
10
|
+
class Label < Phlex::HTML
|
|
11
|
+
def initialize(text: nil, for: nil, required: false, **attributes)
|
|
12
|
+
@text = text
|
|
13
|
+
@for = grab(for:)
|
|
14
|
+
@required = required
|
|
15
|
+
@attributes = attributes
|
|
16
|
+
super()
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def view_template(&block)
|
|
20
|
+
label(for: @for, class: @attributes[:class], **@attributes.except(:class)) do
|
|
21
|
+
if block
|
|
22
|
+
yield
|
|
23
|
+
elsif @text
|
|
24
|
+
span(class: "label") do
|
|
25
|
+
plain @text
|
|
26
|
+
span(class: "text-error ml-1") { "*" } if @required
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|