phlex-forms 0.1.0 → 0.2.1
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 +4 -4
- data/CHANGELOG.md +70 -0
- data/README.md +229 -52
- data/lib/forms/base.rb +100 -0
- data/lib/forms/field.rb +39 -21
- data/lib/forms/fields_for_builder.rb +19 -6
- data/lib/forms/form.rb +38 -10
- data/lib/forms/group.rb +23 -0
- data/lib/forms/live/field.rb +22 -0
- data/lib/forms/live.rb +179 -0
- data/lib/forms/plain/checkbox.rb +17 -0
- data/lib/forms/plain/control.rb +23 -0
- data/lib/forms/plain/field_error.rb +15 -0
- data/lib/forms/plain/field_hint.rb +14 -0
- data/lib/forms/plain/file_input.rb +14 -0
- data/lib/forms/plain/group.rb +15 -0
- data/lib/forms/plain/input.rb +13 -0
- data/lib/forms/plain/label.rb +19 -0
- data/lib/forms/plain/radio.rb +14 -0
- data/lib/forms/plain/row.rb +13 -0
- data/lib/forms/plain/select.rb +22 -0
- data/lib/forms/plain/submit.rb +15 -0
- data/lib/forms/plain/textarea.rb +14 -0
- data/lib/forms/plain/wrapped_input.rb +34 -0
- data/lib/forms/row.rb +34 -0
- data/lib/phlex_forms/builder.rb +51 -18
- data/lib/phlex_forms/configuration.rb +28 -0
- data/lib/phlex_forms/delegated_field.rb +17 -0
- data/lib/phlex_forms/engine.rb +7 -0
- data/lib/phlex_forms/inference.rb +238 -0
- data/lib/phlex_forms/theme.rb +80 -0
- data/lib/phlex_forms/version.rb +1 -1
- data/lib/phlex_forms.rb +26 -1
- metadata +37 -18
data/lib/forms/field.rb
CHANGED
|
@@ -4,13 +4,21 @@
|
|
|
4
4
|
# error set. Builds the concrete leaf components (input/textarea/select/...) with
|
|
5
5
|
# name/id/value/error wired in, and derives the label text and required flag.
|
|
6
6
|
#
|
|
7
|
-
# Not usually built directly — `Form#
|
|
7
|
+
# Not usually built directly — `Form#field_object(:name)` / the builder return one.
|
|
8
8
|
module Forms
|
|
9
9
|
class Field
|
|
10
|
+
# Data keys whose values are Stimulus token lists — merged by joining, not
|
|
11
|
+
# replacing, so validation controllers and live triggers coexist.
|
|
12
|
+
TOKEN_JOINED_DATA_KEYS = %i[controller action].freeze
|
|
13
|
+
|
|
10
14
|
attr_reader :name, :model, :scope, :errors
|
|
11
15
|
|
|
12
|
-
|
|
16
|
+
# error_name: where errors live when it differs from the input's name — an
|
|
17
|
+
# inferred belongs_to select is named :country_id but Rails attaches its
|
|
18
|
+
# errors to :country.
|
|
19
|
+
def initialize(name:, model:, scope:, errors:, form:, error_name: nil)
|
|
13
20
|
@name = name
|
|
21
|
+
@error_name = error_name || name
|
|
14
22
|
@model = model
|
|
15
23
|
@scope = scope
|
|
16
24
|
@errors = errors
|
|
@@ -18,46 +26,48 @@ module Forms
|
|
|
18
26
|
end
|
|
19
27
|
|
|
20
28
|
# --- leaf component builders (return component instances to render) ---
|
|
29
|
+
# Component classes resolve through the form's theme (see
|
|
30
|
+
# PhlexForms::Theme), so the same field renders daisy or plain.
|
|
21
31
|
|
|
22
32
|
def input(*modifiers, type: :text, **)
|
|
23
|
-
|
|
33
|
+
theme[:input].new(*modifiers, type:, **field_attributes, **)
|
|
24
34
|
end
|
|
25
35
|
|
|
26
36
|
def textarea(*modifiers, **)
|
|
27
|
-
|
|
37
|
+
theme[:textarea].new(*modifiers, **field_attributes, **)
|
|
28
38
|
end
|
|
29
39
|
alias text_area textarea
|
|
30
40
|
|
|
31
41
|
def rich_textarea(*modifiers, **)
|
|
32
|
-
|
|
42
|
+
theme[:rich_textarea].new(*modifiers, name: field_name, id: field_id, value: field_value, **)
|
|
33
43
|
end
|
|
34
44
|
alias rich_text_area rich_textarea
|
|
35
45
|
|
|
36
46
|
def hidden(**)
|
|
37
|
-
|
|
47
|
+
theme[:input].new(type: :hidden, **field_attributes, **)
|
|
38
48
|
end
|
|
39
49
|
|
|
40
50
|
# daisyui v5 "icon/text inside the field" wrapper. The block renders the
|
|
41
51
|
# leading content (icon, prefix); the bare input is wired to this field.
|
|
42
52
|
def wrapped_input(*modifiers, type: :text, **, &)
|
|
43
|
-
|
|
53
|
+
theme[:wrapped_input].new(*modifiers, type:, **field_attributes.except(:error), error: invalid?, **, &)
|
|
44
54
|
end
|
|
45
55
|
|
|
46
56
|
def file(*modifiers, **)
|
|
47
|
-
|
|
57
|
+
theme[:file].new(*modifiers, **field_attributes.except(:value), **)
|
|
48
58
|
end
|
|
49
59
|
|
|
50
60
|
def checkbox(*modifiers, **)
|
|
51
|
-
|
|
61
|
+
theme[:checkbox].new(*modifiers, checked: field_value, **field_attributes.except(:value), **)
|
|
52
62
|
end
|
|
53
63
|
alias check_box checkbox
|
|
54
64
|
|
|
55
65
|
def toggle(*modifiers, **)
|
|
56
|
-
|
|
66
|
+
theme[:toggle].new(*modifiers, checked: field_value, **field_attributes.except(:value), **)
|
|
57
67
|
end
|
|
58
68
|
|
|
59
69
|
def radio(value, *modifiers, **options)
|
|
60
|
-
|
|
70
|
+
theme[:radio].new(
|
|
61
71
|
*modifiers,
|
|
62
72
|
value:,
|
|
63
73
|
checked: field_value == value,
|
|
@@ -67,7 +77,7 @@ module Forms
|
|
|
67
77
|
alias radio_button radio
|
|
68
78
|
|
|
69
79
|
def select(choices = nil, **options)
|
|
70
|
-
|
|
80
|
+
theme[:select].new(choices:, selected: field_value, **select_options(options))
|
|
71
81
|
end
|
|
72
82
|
|
|
73
83
|
# Enhanced select: choices.js-backed when searchable, native otherwise.
|
|
@@ -75,18 +85,18 @@ module Forms
|
|
|
75
85
|
searchable = options.delete(:searchable) { false }
|
|
76
86
|
opts = select_options(options)
|
|
77
87
|
if searchable
|
|
78
|
-
|
|
88
|
+
theme[:choices_select].new(*modifiers, choices:, selected: field_value, searchable: true, **opts)
|
|
79
89
|
else
|
|
80
|
-
|
|
90
|
+
theme[:select].new(*modifiers, choices:, selected: field_value, **opts)
|
|
81
91
|
end
|
|
82
92
|
end
|
|
83
93
|
|
|
84
94
|
def label(text = nil, *modifiers, **, &block)
|
|
85
|
-
|
|
95
|
+
theme[:label].new(*modifiers, text: text || (block ? nil : field_label), for: field_id, **, &block)
|
|
86
96
|
end
|
|
87
97
|
|
|
88
98
|
def control(label: nil, hint: nil, required: false, **, &)
|
|
89
|
-
|
|
99
|
+
theme[:control].new(label:, hint:, error: field_error_message, for: field_id, required:, **, &)
|
|
90
100
|
end
|
|
91
101
|
|
|
92
102
|
# --- derived metadata ---
|
|
@@ -113,7 +123,9 @@ module Forms
|
|
|
113
123
|
end
|
|
114
124
|
|
|
115
125
|
def invalid?
|
|
116
|
-
@errors
|
|
126
|
+
return false unless @errors
|
|
127
|
+
|
|
128
|
+
@errors.include?(@name) || @errors.include?(@error_name)
|
|
117
129
|
end
|
|
118
130
|
|
|
119
131
|
def field_name
|
|
@@ -160,6 +172,10 @@ module Forms
|
|
|
160
172
|
|
|
161
173
|
private
|
|
162
174
|
|
|
175
|
+
def theme
|
|
176
|
+
@theme ||= @form.respond_to?(:theme) ? @form.theme : PhlexForms::Theme.resolve(nil)
|
|
177
|
+
end
|
|
178
|
+
|
|
163
179
|
def consume_validate(options)
|
|
164
180
|
return options unless options.key?(:validate)
|
|
165
181
|
|
|
@@ -189,10 +205,10 @@ module Forms
|
|
|
189
205
|
def merge_data(existing, additions)
|
|
190
206
|
existing = (existing || {}).dup
|
|
191
207
|
additions.each do |key, value|
|
|
192
|
-
if key
|
|
193
|
-
|
|
208
|
+
existing[key] = if TOKEN_JOINED_DATA_KEYS.include?(key)
|
|
209
|
+
[existing[key], value].compact.reject { |s| s.to_s.empty? }.join(" ")
|
|
194
210
|
else
|
|
195
|
-
|
|
211
|
+
value
|
|
196
212
|
end
|
|
197
213
|
end
|
|
198
214
|
existing
|
|
@@ -207,7 +223,9 @@ module Forms
|
|
|
207
223
|
end
|
|
208
224
|
|
|
209
225
|
def field_error_message
|
|
210
|
-
|
|
226
|
+
return nil unless invalid?
|
|
227
|
+
|
|
228
|
+
@errors.full_messages_for(@name).first || @errors.full_messages_for(@error_name).first
|
|
211
229
|
end
|
|
212
230
|
|
|
213
231
|
def select_options(options)
|
|
@@ -8,6 +8,7 @@ module Forms
|
|
|
8
8
|
include PhlexForms::Builder
|
|
9
9
|
|
|
10
10
|
attr_reader :model, :scope, :errors, :parent_form
|
|
11
|
+
alias object model # Rails form-builder compatibility (mirrors Forms::Form)
|
|
11
12
|
|
|
12
13
|
def initialize(model:, scope:, errors:, parent_form:)
|
|
13
14
|
@model = model
|
|
@@ -21,16 +22,27 @@ module Forms
|
|
|
21
22
|
@parent_form.render(...)
|
|
22
23
|
end
|
|
23
24
|
|
|
24
|
-
def field_object(name)
|
|
25
|
-
Forms::Field.new(name:, model: @model, scope: @scope, errors: @errors, form: @parent_form)
|
|
25
|
+
def field_object(name, error_name: nil)
|
|
26
|
+
Forms::Field.new(name:, model: @model, scope: @scope, errors: @errors, form: @parent_form, error_name:)
|
|
27
|
+
end
|
|
28
|
+
alias [] field_object # `item_form[:id].hidden` — Rails form-builder-style access
|
|
29
|
+
|
|
30
|
+
def default_field_variants
|
|
31
|
+
@parent_form.default_field_variants
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def theme
|
|
35
|
+
@parent_form.theme
|
|
26
36
|
end
|
|
27
37
|
|
|
28
38
|
# Nested fields_for (single association or has_many collection).
|
|
29
|
-
|
|
39
|
+
# nested_attributes: false nests under the raw name (JSONB/hash columns).
|
|
40
|
+
def fields_for(association_name, model = nil, nested_attributes: true, &)
|
|
30
41
|
return unless block_given?
|
|
31
42
|
|
|
32
43
|
associated = model || (@model.public_send(association_name) if @model.respond_to?(association_name))
|
|
33
|
-
|
|
44
|
+
attributes_key = nested_attributes ? "#{association_name}_attributes" : association_name.to_s
|
|
45
|
+
base_scope = "#{@scope}[#{attributes_key}]"
|
|
34
46
|
|
|
35
47
|
if associated.respond_to?(:each_with_index)
|
|
36
48
|
associated.each_with_index do |item, index|
|
|
@@ -41,8 +53,9 @@ module Forms
|
|
|
41
53
|
end
|
|
42
54
|
end
|
|
43
55
|
|
|
44
|
-
def field_name(name)
|
|
45
|
-
def field_id(name)
|
|
56
|
+
def field_name(name) = "#{@scope}[#{name}]"
|
|
57
|
+
def field_id(name) = "#{@scope.tr('[', '_').delete(']')}_#{name}"
|
|
58
|
+
def field_value(name) = field_object(name).field_value
|
|
46
59
|
|
|
47
60
|
private
|
|
48
61
|
|
data/lib/forms/form.rb
CHANGED
|
@@ -17,14 +17,32 @@ module Forms
|
|
|
17
17
|
include Phlex::Rails::Helpers::FormAuthenticityToken if defined?(Phlex::Rails::Helpers::FormAuthenticityToken)
|
|
18
18
|
include PhlexForms::Builder
|
|
19
19
|
|
|
20
|
-
attr_reader :model, :scope, :url, :method, :errors, :validate
|
|
20
|
+
attr_reader :model, :scope, :url, :method, :errors, :validate, :theme
|
|
21
|
+
|
|
22
|
+
# Rails form-builder compatibility: `f.object` / `f.object_name` mirror the
|
|
23
|
+
# ActionView FormBuilder API so call sites that introspect the bound record
|
|
24
|
+
# (`f.object.class.reflect_on_association(...)`) keep working.
|
|
25
|
+
alias object model
|
|
26
|
+
alias object_name scope
|
|
27
|
+
|
|
28
|
+
def initialize(*modifiers, model: nil, scope: nil, url: nil, method: nil, validate: false,
|
|
29
|
+
field_variants: nil, theme: nil, live: nil, **options)
|
|
30
|
+
if live
|
|
31
|
+
raise ArgumentError,
|
|
32
|
+
"live validation requires a Forms::Base subclass — the endpoint rebuilds the " \
|
|
33
|
+
"form from its class, and an inline block cannot be serialized. Declare " \
|
|
34
|
+
"`live model: #{record_from(model)&.class&.name || 'YourModel'}` on the class instead."
|
|
35
|
+
end
|
|
21
36
|
|
|
22
|
-
def initialize(*modifiers, model: nil, scope: nil, url: nil, method: nil, validate: false, **options)
|
|
23
37
|
super()
|
|
24
38
|
@base_modifiers = modifiers
|
|
39
|
+
@field_variants = Array(field_variants)
|
|
40
|
+
@theme = PhlexForms::Theme.resolve(theme)
|
|
25
41
|
@options = options
|
|
26
42
|
@model = record_from(model)
|
|
27
|
-
|
|
43
|
+
# scope: false opts out of scoping entirely — bare field names for
|
|
44
|
+
# reactive row editors / <template>-cloned rows. nil means "derive".
|
|
45
|
+
@scope = scope == false ? nil : (scope&.to_s || derive_scope(@model))
|
|
28
46
|
@url = url || derive_url(model)
|
|
29
47
|
@method = method || derive_method(@model)
|
|
30
48
|
@errors = (@model.errors if @model.respond_to?(:errors))
|
|
@@ -51,12 +69,13 @@ module Forms
|
|
|
51
69
|
end
|
|
52
70
|
|
|
53
71
|
# 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)
|
|
72
|
+
def field_object(name, error_name: nil)
|
|
73
|
+
Forms::Field.new(name:, model: @model, scope: @scope, errors: @errors, form: self, error_name:)
|
|
56
74
|
end
|
|
75
|
+
alias [] field_object # `f[:email].hidden` — Rails form-builder-style access
|
|
57
76
|
|
|
58
77
|
def submit(*, **, &)
|
|
59
|
-
render
|
|
78
|
+
render theme[:submit].new(*, model: @model, **, &)
|
|
60
79
|
end
|
|
61
80
|
|
|
62
81
|
def rich_textarea(name, *modifiers, **)
|
|
@@ -76,11 +95,13 @@ module Forms
|
|
|
76
95
|
|
|
77
96
|
# Nested attributes. Yields a FieldsForBuilder per association (single) or per
|
|
78
97
|
# item (has_many), with the correctly-indexed nested scope.
|
|
79
|
-
|
|
98
|
+
# nested_attributes: false nests under the raw name (no `_attributes` suffix)
|
|
99
|
+
# for JSONB/hash columns that aren't Rails nested attributes.
|
|
100
|
+
def fields_for(association_name, model = nil, nested_attributes: true, &)
|
|
80
101
|
return unless block_given?
|
|
81
102
|
|
|
82
103
|
associated = model || (@model.public_send(association_name) if @model.respond_to?(association_name))
|
|
83
|
-
attributes_key = "#{association_name}_attributes"
|
|
104
|
+
attributes_key = nested_attributes ? "#{association_name}_attributes" : association_name.to_s
|
|
84
105
|
base_scope = @scope ? "#{@scope}[#{attributes_key}]" : attributes_key
|
|
85
106
|
|
|
86
107
|
if associated.respond_to?(:each_with_index)
|
|
@@ -122,9 +143,16 @@ module Forms
|
|
|
122
143
|
render field_object(name).select(choices, **options.except(:prompt), **html_options)
|
|
123
144
|
end
|
|
124
145
|
|
|
146
|
+
# Default variants prepended to every `field`'s inner input: global config
|
|
147
|
+
# first, then this form's field_variants: (call-site modifiers stack last).
|
|
148
|
+
def default_field_variants
|
|
149
|
+
PhlexForms.config.field_variants + @field_variants
|
|
150
|
+
end
|
|
151
|
+
|
|
125
152
|
# Public name/id/value helpers for external components mirroring the Rails API.
|
|
126
|
-
def field_name(name)
|
|
127
|
-
def field_id(name)
|
|
153
|
+
def field_name(name) = @scope ? "#{@scope}[#{name}]" : name.to_s
|
|
154
|
+
def field_id(name) = @scope ? "#{@scope}_#{name}" : name.to_s
|
|
155
|
+
def field_value(name) = field_object(name).field_value
|
|
128
156
|
|
|
129
157
|
private
|
|
130
158
|
|
data/lib/forms/group.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
# A daisyui fieldset with an optional legend, for sectioning related fields.
|
|
5
|
+
# Exposed as `f.group` (not `section`/`fieldset` — those are Phlex::HTML
|
|
6
|
+
# element methods and defining them would shadow the elements inside forms).
|
|
7
|
+
#
|
|
8
|
+
# f.group(legend: "Address") { f.field :street; f.field :city }
|
|
9
|
+
class Group < Phlex::HTML
|
|
10
|
+
def initialize(legend: nil, **options)
|
|
11
|
+
@legend = legend
|
|
12
|
+
@options = options
|
|
13
|
+
super()
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def view_template
|
|
17
|
+
render DaisyUI::Fieldset.new(**@options) do |fs|
|
|
18
|
+
fs.legend { @legend } if @legend
|
|
19
|
+
yield if block_given?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
module Live
|
|
5
|
+
# A Forms::Field that merges the blur-time validate trigger into every
|
|
6
|
+
# input's data attributes (via apply_validations, the seam every builder
|
|
7
|
+
# already routes options through).
|
|
8
|
+
class Field < Forms::Field
|
|
9
|
+
def initialize(live_trigger: nil, **)
|
|
10
|
+
super(**)
|
|
11
|
+
@live_trigger = live_trigger
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def apply_validations(options)
|
|
15
|
+
merged = super
|
|
16
|
+
return merged unless @live_trigger
|
|
17
|
+
|
|
18
|
+
merged.merge(data: merge_data(merged[:data], @live_trigger[:data]))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/forms/live.rb
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
# Server-truth live validation via phlex-reactive: the whole form is one
|
|
5
|
+
# reactive component. Each input's blur (and a debounced form-wide input
|
|
6
|
+
# trigger) POSTs every field to a single :validate action, which assigns a
|
|
7
|
+
# whitelisted slice to the model, runs the REAL ActiveModel validators —
|
|
8
|
+
# uniqueness, :if/:unless, confirmation, :on context all work — and replies
|
|
9
|
+
# with a focus-preserving morph. Nothing is ever persisted.
|
|
10
|
+
#
|
|
11
|
+
# Enabled through the Forms::Base macro (inline forms cannot be live — the
|
|
12
|
+
# endpoint rebuilds the component from its class, and a caller-supplied
|
|
13
|
+
# block cannot be serialized):
|
|
14
|
+
#
|
|
15
|
+
# class UserForm < Forms::Base
|
|
16
|
+
# live model: User, debounce: 300
|
|
17
|
+
# def fields
|
|
18
|
+
# field :email
|
|
19
|
+
# field :password
|
|
20
|
+
# field :password_confirmation
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# Identity is STATE-backed (not reactive_record): the token signs the
|
|
25
|
+
# model's GlobalID when persisted (nil for new records — reactive_record
|
|
26
|
+
# cannot round-trip an unsaved draft) plus the `touched` field list, so
|
|
27
|
+
# error display state survives every round trip tamper-proof, with zero
|
|
28
|
+
# client-side bookkeeping.
|
|
29
|
+
module Live
|
|
30
|
+
extend ActiveSupport::Concern
|
|
31
|
+
include Phlex::Reactive::Component
|
|
32
|
+
|
|
33
|
+
# Registers the raw-hash param type the :validate schema is declared with.
|
|
34
|
+
# Called from the engine initializer (the registry freezes after boot);
|
|
35
|
+
# test suites call it from their spec helper.
|
|
36
|
+
def self.register_param_type!
|
|
37
|
+
return if Phlex::Reactive.param_type?(:form_attributes)
|
|
38
|
+
|
|
39
|
+
Phlex::Reactive.param_type(:form_attributes) do |value|
|
|
40
|
+
value.is_a?(Hash) ? value : Phlex::Reactive::ParamSchema::DROP
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class_methods do
|
|
45
|
+
def setup_live(model_class:, scope:, debounce:)
|
|
46
|
+
@live_model_class = model_class
|
|
47
|
+
@live_scope = scope.to_s
|
|
48
|
+
@live_debounce = debounce
|
|
49
|
+
reactive_state :model_gid, :touched
|
|
50
|
+
action :validate, params: { scope.to_sym => :form_attributes, _touch: :string }
|
|
51
|
+
# phlex-reactive >= 0.11 defaults verify_authorized ON: an action that
|
|
52
|
+
# completes without an authorization call raises AuthorizationNotVerified
|
|
53
|
+
# and rolls back (#168). :validate is a deliberate no-persist, read-only
|
|
54
|
+
# pass — it assigns, validates, and discards; native submit stays
|
|
55
|
+
# authoritative — so it needs no authorization. Guarded so it's a no-op
|
|
56
|
+
# on older phlex-reactive that lacks the macro.
|
|
57
|
+
skip_verify_authorized :validate if respond_to?(:skip_verify_authorized)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def live_model_class = @live_model_class || inherited_live(:live_model_class)
|
|
61
|
+
def live_scope = @live_scope || inherited_live(:live_scope)
|
|
62
|
+
def live_debounce = @live_debounce || inherited_live(:live_debounce)
|
|
63
|
+
|
|
64
|
+
# Narrow (live_permit) or trim (live_deny) the attributes the :validate
|
|
65
|
+
# action may assign. Default: the model's column/attribute names plus
|
|
66
|
+
# every validated attribute (and its _confirmation twin).
|
|
67
|
+
def live_permit(*attrs) = @live_permit = attrs.map(&:to_s)
|
|
68
|
+
def live_deny(*attrs) = @live_deny = attrs.map(&:to_s)
|
|
69
|
+
|
|
70
|
+
def live_permitted_attributes(model)
|
|
71
|
+
permitted = @live_permit || derived_live_attributes(model)
|
|
72
|
+
permitted - (@live_deny || [])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def inherited_live(reader)
|
|
78
|
+
superclass.respond_to?(reader) ? superclass.public_send(reader) : nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def derived_live_attributes(model)
|
|
82
|
+
klass = model.class
|
|
83
|
+
names = []
|
|
84
|
+
names.concat(klass.attribute_names.map(&:to_s)) if klass.respond_to?(:attribute_names)
|
|
85
|
+
if klass.respond_to?(:validators)
|
|
86
|
+
klass.validators.each do |validator|
|
|
87
|
+
validator.attributes.each do |attr|
|
|
88
|
+
names << attr.to_s
|
|
89
|
+
names << "#{attr}_confirmation" if validator.is_a?(ActiveModel::Validations::ConfirmationValidator)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
names.uniq
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Two construction modes: the app renders `new(model:)`; the endpoint
|
|
98
|
+
# rebuilds `new(model_gid:, touched:)` from the signed state (see
|
|
99
|
+
# Component::DSL.from_identity — only declared state keys round-trip).
|
|
100
|
+
def initialize(*modifiers, model: nil, model_gid: nil, touched: nil, **)
|
|
101
|
+
model ||= locate_live_model(model_gid)
|
|
102
|
+
@touched = Array(touched).map(&:to_s)
|
|
103
|
+
super(*modifiers, model:, **)
|
|
104
|
+
@model_gid = (@model.to_gid.to_s if signed_model_gid?)
|
|
105
|
+
# A form re-rendered after a failed submit (classic 422) arrives with
|
|
106
|
+
# errors already on the model — surface them without requiring touches.
|
|
107
|
+
@touched |= @errors.attribute_names.map(&:to_s) if @errors.respond_to?(:attribute_names)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Streamable's #id contract: the reactive root's stable DOM id.
|
|
111
|
+
def id
|
|
112
|
+
@options[:id] || "#{@model.respond_to?(:persisted?) && @model.persisted? ? 'edit' : 'new'}_#{@scope}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Assign → validate → morph. The reply preserves the focused input and its
|
|
116
|
+
# caret (Idiomorph); the fresh token re-signs the updated touched set.
|
|
117
|
+
# `_touch` is the wire key (underscored so it can never collide with a
|
|
118
|
+
# model attribute) — the schema splats it back as this keyword.
|
|
119
|
+
def validate(_touch: nil, **posted) # rubocop:disable Lint/UnderscorePrefixedVariableName
|
|
120
|
+
@touched |= [_touch.to_s] if _touch
|
|
121
|
+
assign_live_attributes(posted[self.class.live_scope.to_sym])
|
|
122
|
+
@model.validate
|
|
123
|
+
reply.morph
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The <form> element is the reactive root: id + controller + signed token,
|
|
127
|
+
# plus the debounced whole-form input trigger (input events bubble; blur
|
|
128
|
+
# triggers live per-input via Live::Field because Stimulus params are
|
|
129
|
+
# per-element).
|
|
130
|
+
def form_attributes
|
|
131
|
+
attrs = super
|
|
132
|
+
mix(
|
|
133
|
+
attrs,
|
|
134
|
+
reactive_root(id: attrs[:id] || id),
|
|
135
|
+
on(:validate, event: "input", debounce: self.class.live_debounce)
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Untouched fields get no error set, so nothing flashes before the user
|
|
140
|
+
# leaves a field; each input carries its blur _touch trigger.
|
|
141
|
+
def field_object(name, error_name: nil)
|
|
142
|
+
show_errors = touched?(name) || touched?(error_name)
|
|
143
|
+
Forms::Live::Field.new(
|
|
144
|
+
name:, model: @model, scope: @scope, form: self, error_name:,
|
|
145
|
+
errors: (show_errors ? @errors : nil),
|
|
146
|
+
live_trigger: on(:validate, event: "blur", _touch: name.to_s)
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def touched?(name)
|
|
151
|
+
name && @touched.include?(name.to_s)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def locate_live_model(model_gid)
|
|
155
|
+
return GlobalID::Locator.locate(model_gid) if model_gid.present?
|
|
156
|
+
|
|
157
|
+
self.class.live_model_class.new
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def signed_model_gid?
|
|
161
|
+
@model.respond_to?(:to_gid) && @model.respond_to?(:persisted?) && @model.persisted?
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Whitelisted assignment through public writers. Values are validated and
|
|
165
|
+
# rendered, never saved; use live_permit/live_deny to adjust the surface.
|
|
166
|
+
def assign_live_attributes(attrs)
|
|
167
|
+
return if attrs.blank?
|
|
168
|
+
|
|
169
|
+
permitted = self.class.live_permitted_attributes(@model)
|
|
170
|
+
attrs.each do |key, value|
|
|
171
|
+
key = key.to_s
|
|
172
|
+
next unless permitted.include?(key)
|
|
173
|
+
|
|
174
|
+
setter = :"#{key}="
|
|
175
|
+
@model.public_send(setter, value) if @model.respond_to?(setter)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
module Plain
|
|
5
|
+
# Bare checkbox, keeping the hidden unchecked-value field (that's binding
|
|
6
|
+
# logic, not styling). Also fills the :toggle theme role — a toggle is a
|
|
7
|
+
# styled checkbox.
|
|
8
|
+
class Checkbox < Forms::Checkbox
|
|
9
|
+
def view_template
|
|
10
|
+
input(type: "hidden", name: @name, value: @unchecked_value) if @unchecked_value && @name
|
|
11
|
+
attrs = unstyled_attributes
|
|
12
|
+
attrs[:checked] = true if @checked
|
|
13
|
+
input(type: "checkbox", value: @value, **attrs)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
module Plain
|
|
5
|
+
# The label + field + error/hint wrapper as a bare <div>, preserving
|
|
6
|
+
# FormControl's ordering contract.
|
|
7
|
+
class Control < Forms::FormControl
|
|
8
|
+
def view_template
|
|
9
|
+
div(**@options.except(:class), class: @options[:class]) do
|
|
10
|
+
render Label.new(text: @label, for: @field_id, required: @required) if @label
|
|
11
|
+
|
|
12
|
+
yield if block_given?
|
|
13
|
+
|
|
14
|
+
if @error
|
|
15
|
+
render FieldError.new(message: @error)
|
|
16
|
+
elsif @hint
|
|
17
|
+
render FieldHint.new(text: @hint)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
module Plain
|
|
5
|
+
# Inline validation error: no styling classes, stable hooks only
|
|
6
|
+
# (role="alert" + data-field-error).
|
|
7
|
+
class FieldError < Forms::FieldError
|
|
8
|
+
def view_template
|
|
9
|
+
return unless @message
|
|
10
|
+
|
|
11
|
+
p(role: "alert", data: { field_error: true }, **@options) { @message }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
module Plain
|
|
5
|
+
# Inline hint text with a data-field-hint hook, no styling classes.
|
|
6
|
+
class FieldHint < Forms::FieldHint
|
|
7
|
+
def view_template
|
|
8
|
+
return unless @text
|
|
9
|
+
|
|
10
|
+
p(data: { field_hint: true }, **@options) { @text }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
module Plain
|
|
5
|
+
# Bare <input type="file">.
|
|
6
|
+
class FileInput < Forms::FileInput
|
|
7
|
+
def view_template
|
|
8
|
+
attrs = unstyled_attributes(accept: @accept)
|
|
9
|
+
attrs[:multiple] = true if @multiple
|
|
10
|
+
input(type: "file", **attrs)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
module Plain
|
|
5
|
+
# Semantic <fieldset>/<legend> without daisyui classes.
|
|
6
|
+
class Group < Forms::Group
|
|
7
|
+
def view_template
|
|
8
|
+
fieldset(**@options.except(:class), class: @options[:class]) do
|
|
9
|
+
legend { @legend } if @legend
|
|
10
|
+
yield if block_given?
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
module Plain
|
|
5
|
+
# Bare semantic <input>. Inherits the binding contract from Forms::Input;
|
|
6
|
+
# positional variants are accepted and ignored.
|
|
7
|
+
class Input < Forms::Input
|
|
8
|
+
def view_template
|
|
9
|
+
input(type: @type, value: @value.to_s, **unstyled_attributes)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
module Plain
|
|
5
|
+
# Bare <label>, with the required marker as a semantic <abbr>.
|
|
6
|
+
class Label < Forms::Label
|
|
7
|
+
def view_template(&block)
|
|
8
|
+
label(for: @for, **@attributes) do
|
|
9
|
+
if block
|
|
10
|
+
yield
|
|
11
|
+
elsif @text
|
|
12
|
+
plain @text
|
|
13
|
+
abbr(title: "required") { "*" } if @required
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|