phlex-forms 0.1.0 → 0.2.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 +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 +17 -6
- data/lib/forms/form.rb +31 -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
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
module Plain
|
|
5
|
+
# Bare native <select>. Reuses Forms::Select's prompt/choice rendering
|
|
6
|
+
# (arrays, pairs, hashes/optgroups, selected comparison). Also fills the
|
|
7
|
+
# :choices_select theme role, so it swallows searchable: — there is no
|
|
8
|
+
# choices.js in the plain theme.
|
|
9
|
+
class Select < Forms::Select
|
|
10
|
+
def initialize(*modifiers, searchable: false, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
11
|
+
super(*modifiers, **)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def view_template
|
|
15
|
+
select(**unstyled_attributes) do
|
|
16
|
+
render_prompt(self) if @prompt || @include_blank
|
|
17
|
+
render_choices(self)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
module Plain
|
|
5
|
+
# Bare <button type="submit">, reusing Forms::Submit's Create/Update label
|
|
6
|
+
# derivation.
|
|
7
|
+
class Submit < Forms::Submit
|
|
8
|
+
def view_template(&block)
|
|
9
|
+
attrs = { type: "submit", **@attributes }
|
|
10
|
+
attrs[:disabled] = true if @disabled
|
|
11
|
+
button(**attrs) { block ? yield : button_text }
|
|
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
|
+
# Bare <textarea>. Also fills the :rich_textarea theme role (documented
|
|
6
|
+
# degradation — rich text needs an editor integration the plain theme
|
|
7
|
+
# doesn't ship), so it swallows the rich-text-only value handling.
|
|
8
|
+
class Textarea < Forms::Textarea
|
|
9
|
+
def view_template
|
|
10
|
+
textarea(rows: @rows, **unstyled_attributes) { @value }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
module Plain
|
|
5
|
+
# The icon/text-inside-the-field pattern without daisyui: a bare <label>
|
|
6
|
+
# wrapping the leading block, the input, and any trailing content.
|
|
7
|
+
class WrappedInput < Forms::WrappedInput
|
|
8
|
+
def view_template(&leading)
|
|
9
|
+
label do
|
|
10
|
+
leading&.call
|
|
11
|
+
input(**plain_input_attributes)
|
|
12
|
+
render_trailing
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def plain_input_attributes
|
|
19
|
+
attrs = {
|
|
20
|
+
type: @type,
|
|
21
|
+
name: @name,
|
|
22
|
+
id: @id,
|
|
23
|
+
value: @value.to_s,
|
|
24
|
+
placeholder: @placeholder,
|
|
25
|
+
**@attributes.except(:error, :value, :class)
|
|
26
|
+
}
|
|
27
|
+
attrs[:disabled] = true if @disabled
|
|
28
|
+
attrs[:required] = true if @required
|
|
29
|
+
attrs[:"aria-invalid"] = true if @error
|
|
30
|
+
attrs.compact
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/forms/row.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Forms
|
|
4
|
+
# Side-by-side fields in a responsive grid: stacked on mobile, `columns`
|
|
5
|
+
# across from the sm breakpoint. The class strings are literals (not
|
|
6
|
+
# interpolated) so host Tailwind scanners pick them up.
|
|
7
|
+
#
|
|
8
|
+
# f.row { f.field :first_name; f.field :last_name }
|
|
9
|
+
# f.row(columns: 3) { ... }
|
|
10
|
+
class Row < Phlex::HTML
|
|
11
|
+
COLUMN_CLASSES = {
|
|
12
|
+
2 => "sm:grid-cols-2",
|
|
13
|
+
3 => "sm:grid-cols-3",
|
|
14
|
+
4 => "sm:grid-cols-4"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(columns: 2, **options)
|
|
18
|
+
@columns = columns
|
|
19
|
+
@options = options
|
|
20
|
+
super()
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def view_template(&)
|
|
24
|
+
div(class: row_classes, **@options.except(:class), &)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def row_classes
|
|
30
|
+
base = ["grid grid-cols-1 gap-4", COLUMN_CLASSES[@columns]].compact.join(" ")
|
|
31
|
+
PhlexForms::ClassMerge.merge(base, @options[:class])
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/lib/phlex_forms/builder.rb
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PhlexForms
|
|
4
|
-
# The shared form-builder surface, included by Forms::Form
|
|
5
|
-
# Forms::FieldsForBuilder so the
|
|
4
|
+
# The shared form-builder surface, included by Forms::Form and
|
|
5
|
+
# Forms::FieldsForBuilder so the two no longer copy-paste the API.
|
|
6
6
|
#
|
|
7
7
|
# Two layers:
|
|
8
8
|
#
|
|
9
9
|
# * The Control-first primary verb `field` — renders label + input +
|
|
10
10
|
# error/hint in one call, inferring the input type and the `required` flag
|
|
11
|
-
# from the model
|
|
11
|
+
# from the model (structure, column types, validators — see
|
|
12
|
+
# PhlexForms::Inference) with the attribute-name map as tiebreaker.
|
|
12
13
|
#
|
|
13
14
|
# * Lower-level escape hatches (`Input`, `Select`, `Textarea`, `Checkbox`,
|
|
14
15
|
# `Toggle`, `FileInput`, `Hidden`, `Label`, `Control`, `submit`) with the
|
|
@@ -48,9 +49,10 @@ module PhlexForms
|
|
|
48
49
|
# Render a complete field: label + control-wrapped input + error/hint.
|
|
49
50
|
#
|
|
50
51
|
# f.field :email # type=email, label auto, required inferred
|
|
51
|
-
# f.field :
|
|
52
|
-
# f.field :
|
|
53
|
-
# f.field :
|
|
52
|
+
# f.field :notify # boolean column -> toggle
|
|
53
|
+
# f.field :role # AR enum -> select, humanized
|
|
54
|
+
# f.field :country # belongs_to -> select over the association
|
|
55
|
+
# f.field :bio, as: :textarea, rows: 6 # explicit as: always wins
|
|
54
56
|
# f.field :name, :primary, label: "Full name", hint: "As on your ID"
|
|
55
57
|
#
|
|
56
58
|
# Options:
|
|
@@ -58,22 +60,48 @@ module PhlexForms
|
|
|
58
60
|
# pass false to omit the label)
|
|
59
61
|
# hint: help text shown when there is no error
|
|
60
62
|
# as: override the rendered control (:select, :textarea, :toggle,
|
|
61
|
-
# :checkbox, :file, :hidden, and text-like types)
|
|
63
|
+
# :checkbox, :file, :hidden, :rich_textarea, and text-like types)
|
|
62
64
|
# required: force the required flag (otherwise inferred from validations)
|
|
63
|
-
# choices: choices for :select
|
|
65
|
+
# choices: choices for :select (implies as: :select when given)
|
|
64
66
|
# Remaining positional modifiers/keywords pass through to the inner input.
|
|
65
67
|
def field(name, *modifiers, label: nil, hint: nil, as: nil, required: nil,
|
|
66
68
|
choices: nil, **options)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
modifiers = default_field_variants + modifiers
|
|
70
|
+
inferred = PhlexForms::Inference.resolve(model:, name:, as:, modifiers:, choices:)
|
|
71
|
+
# error_name: for a rewritten field (:country -> :country_id) errors still
|
|
72
|
+
# live on the association name.
|
|
73
|
+
fo = field_object(inferred.name, error_name: name)
|
|
74
|
+
req = required.nil? ? (fo.required? || inferred.required == true) : required
|
|
75
|
+
label_text = label == false ? nil : (label || inferred.label || fo.field_label)
|
|
76
|
+
options = inferred.attributes.merge(options)
|
|
77
|
+
if inferred.multiple
|
|
78
|
+
options[:multiple] = true unless options.key?(:multiple)
|
|
79
|
+
options[:name] ||= "#{fo.field_name}[]"
|
|
80
|
+
end
|
|
70
81
|
options = fo.apply_validations(options)
|
|
82
|
+
choices ||= materialize_choices(inferred.choices)
|
|
71
83
|
|
|
72
84
|
render fo.control(label: label_text, hint:, required: req) do
|
|
73
|
-
render_field_input(fo, name, as, modifiers, choices:, required: req, **options)
|
|
85
|
+
render_field_input(fo, inferred.name, inferred.as, modifiers, choices:, required: req, **options)
|
|
74
86
|
end
|
|
75
87
|
end
|
|
76
88
|
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
# Layout helpers
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
# Side-by-side fields in a responsive grid (stacked on mobile).
|
|
94
|
+
# f.row { f.field :first_name; f.field :last_name }
|
|
95
|
+
def row(columns: 2, **, &)
|
|
96
|
+
render theme[:row].new(columns:, **), &
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# A fieldset with a legend for sectioning related fields.
|
|
100
|
+
# f.group(legend: "Address") { f.field :street }
|
|
101
|
+
def group(legend: nil, **, &)
|
|
102
|
+
render theme[:group].new(legend:, **), &
|
|
103
|
+
end
|
|
104
|
+
|
|
77
105
|
# ------------------------------------------------------------------
|
|
78
106
|
# Escape-hatch component API (stable signatures)
|
|
79
107
|
# ------------------------------------------------------------------
|
|
@@ -133,18 +161,23 @@ module PhlexForms
|
|
|
133
161
|
def render_field_input(fo, name, as, modifiers, choices:, required:, **)
|
|
134
162
|
kind = as || resolve_input_type(name, modifiers)
|
|
135
163
|
case kind
|
|
136
|
-
when :select
|
|
137
|
-
when :textarea
|
|
138
|
-
when :toggle
|
|
139
|
-
when :checkbox
|
|
140
|
-
when :file
|
|
141
|
-
when :hidden
|
|
164
|
+
when :select then render fo.choices_select(choices, *modifiers, required:, **)
|
|
165
|
+
when :textarea then render fo.textarea(*modifiers, required:, **)
|
|
166
|
+
when :toggle then render fo.toggle(*modifiers, required:, **)
|
|
167
|
+
when :checkbox then render fo.checkbox(*modifiers, required:, **)
|
|
168
|
+
when :file then render fo.file(*modifiers, required:, **)
|
|
169
|
+
when :hidden then render fo.hidden(**)
|
|
170
|
+
when :rich_textarea then render fo.rich_textarea(*modifiers, **)
|
|
142
171
|
else
|
|
143
172
|
type = kind == :datetime ? :"datetime-local" : kind
|
|
144
173
|
render fo.input(*(modifiers - INPUT_TYPE_MODIFIERS), type:, required:, **)
|
|
145
174
|
end
|
|
146
175
|
end
|
|
147
176
|
|
|
177
|
+
def materialize_choices(choices)
|
|
178
|
+
choices.respond_to?(:call) ? choices.call : choices
|
|
179
|
+
end
|
|
180
|
+
|
|
148
181
|
def resolve_input_type(name, modifiers)
|
|
149
182
|
explicit = modifiers.find { |m| INPUT_TYPE_MODIFIERS.include?(m) }
|
|
150
183
|
explicit || INPUT_TYPE_INFERENCE[name.to_sym] || :text
|
|
@@ -36,6 +36,34 @@ module PhlexForms
|
|
|
36
36
|
|
|
37
37
|
attr_writer :icon_renderer
|
|
38
38
|
|
|
39
|
+
# Kill-switch for model-driven inference (columns/enums/associations/
|
|
40
|
+
# validator attributes). Defaults to on; set false to restore pure
|
|
41
|
+
# attribute-name inference for `field`.
|
|
42
|
+
attr_writer :infer_from_model
|
|
43
|
+
|
|
44
|
+
# Global daisyui variants prepended to every `field`'s inner input, e.g.
|
|
45
|
+
# `c.field_variants = [:primary]`. Form-level `field_variants:` and
|
|
46
|
+
# call-site modifiers stack after (later wins).
|
|
47
|
+
attr_writer :field_variants
|
|
48
|
+
|
|
49
|
+
def field_variants
|
|
50
|
+
@field_variants ||= []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The default theme: :daisy, :plain, or a PhlexForms::Theme instance.
|
|
54
|
+
# Defaults to daisy when the daisyui gem is loaded, plain otherwise.
|
|
55
|
+
attr_writer :theme
|
|
56
|
+
|
|
57
|
+
def theme
|
|
58
|
+
@theme ||= defined?(DaisyUI) ? Theme.daisy : Theme.plain
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def infer_from_model
|
|
62
|
+
return @infer_from_model if defined?(@infer_from_model) && !@infer_from_model.nil?
|
|
63
|
+
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
|
|
39
67
|
def icon_renderer
|
|
40
68
|
@icon_renderer ||= ->(name, **opts) { InlineIcons.render(name, **opts) }
|
|
41
69
|
end
|
|
@@ -47,5 +47,22 @@ module PhlexForms
|
|
|
47
47
|
|
|
48
48
|
["w-full", @attributes[:class]].compact.join(" ")
|
|
49
49
|
end
|
|
50
|
+
|
|
51
|
+
# Unstyled variant of binding_attributes for the Plain theme: caller classes
|
|
52
|
+
# pass through verbatim, no width class, and an invalid field is flagged via
|
|
53
|
+
# aria-invalid instead of an error variant class.
|
|
54
|
+
def unstyled_attributes(**extra)
|
|
55
|
+
attrs = {
|
|
56
|
+
name: @name,
|
|
57
|
+
id: @id,
|
|
58
|
+
class: @attributes[:class],
|
|
59
|
+
**@attributes.except(:error, :value, :class),
|
|
60
|
+
**extra
|
|
61
|
+
}
|
|
62
|
+
attrs[:disabled] = true if @disabled
|
|
63
|
+
attrs[:required] = true if @required
|
|
64
|
+
attrs[:"aria-invalid"] = true if @error
|
|
65
|
+
attrs.compact
|
|
66
|
+
end
|
|
50
67
|
end
|
|
51
68
|
end
|
data/lib/phlex_forms/engine.rb
CHANGED
|
@@ -31,5 +31,12 @@ module PhlexForms
|
|
|
31
31
|
locales = Dir[root.join("config/locales/*.yml")].map(&:to_s)
|
|
32
32
|
app.config.i18n.load_path.unshift(*locales)
|
|
33
33
|
end
|
|
34
|
+
|
|
35
|
+
# The raw-hash param type Forms::Live's :validate action schema declares.
|
|
36
|
+
# Registered from an initializer because phlex-reactive freezes its
|
|
37
|
+
# param-type registry after boot.
|
|
38
|
+
initializer "phlex_forms.live_param_type" do
|
|
39
|
+
Forms::Live.register_param_type! if defined?(Phlex::Reactive)
|
|
40
|
+
end
|
|
34
41
|
end
|
|
35
42
|
end
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PhlexForms
|
|
4
|
+
# Model-driven input inference for Builder#field. Pure introspection: every
|
|
5
|
+
# model touch sits behind respond_to? guards and a StandardError rescue (the
|
|
6
|
+
# same posture as Forms::Field#required?), so plain objects, Structs, and
|
|
7
|
+
# POROs fall through to the attribute-name map exactly as before. No
|
|
8
|
+
# ActiveRecord dependency.
|
|
9
|
+
#
|
|
10
|
+
# Precedence (first hit wins):
|
|
11
|
+
# 1. explicit as: (caller always wins)
|
|
12
|
+
# 2. positional type modifier (f.field :price, :number)
|
|
13
|
+
# 3. explicit choices: -> :select
|
|
14
|
+
# 4. model structure: rich text, attachment, enum, belongs_to
|
|
15
|
+
# 5. non-string column type (COLUMN_TYPE_MAP)
|
|
16
|
+
# 6. attribute-name map (Builder::INPUT_TYPE_INFERENCE)
|
|
17
|
+
# 7. :text
|
|
18
|
+
#
|
|
19
|
+
# Validator-derived attributes (maxlength/min/max) merge orthogonally and
|
|
20
|
+
# always lose to caller-passed options. Steps 4-5 and the validator attrs are
|
|
21
|
+
# gated by `PhlexForms.config.infer_from_model` (default on).
|
|
22
|
+
module Inference
|
|
23
|
+
Result = Data.define(:as, :name, :label, :choices, :attributes, :multiple, :required) do
|
|
24
|
+
def self.blank(as:, name:)
|
|
25
|
+
new(as:, name:, label: nil, choices: nil, attributes: {}, multiple: false, required: nil)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Option-text methods tried in order when building association choices.
|
|
30
|
+
LABEL_METHODS = %i[name title label to_s].freeze
|
|
31
|
+
|
|
32
|
+
# Column type -> control kind, for non-string columns only (the name map
|
|
33
|
+
# disambiguates strings; the column type is ground truth for other shapes).
|
|
34
|
+
COLUMN_TYPE_MAP = {
|
|
35
|
+
boolean: :toggle,
|
|
36
|
+
text: :textarea,
|
|
37
|
+
date: :date,
|
|
38
|
+
datetime: :datetime,
|
|
39
|
+
timestamptz: :datetime,
|
|
40
|
+
time: :time,
|
|
41
|
+
integer: :number,
|
|
42
|
+
decimal: :number,
|
|
43
|
+
float: :number
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# Kinds that accept a maxlength attribute.
|
|
47
|
+
TEXT_LIKE = %i[text email password tel url search textarea].freeze
|
|
48
|
+
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
def resolve(model:, name:, as: nil, modifiers: [], choices: nil)
|
|
52
|
+
result = base_result(model:, name:, as:, modifiers:, choices:)
|
|
53
|
+
return result unless infer_from_model?
|
|
54
|
+
|
|
55
|
+
validator_attrs = validator_attributes(model, result.name, result.as)
|
|
56
|
+
return result if validator_attrs.empty?
|
|
57
|
+
|
|
58
|
+
result.with(attributes: validator_attrs.merge(result.attributes))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def base_result(model:, name:, as:, modifiers:, choices:)
|
|
62
|
+
return Result.blank(as:, name:) if as
|
|
63
|
+
|
|
64
|
+
if (explicit = modifiers.find { |m| Builder::INPUT_TYPE_MODIFIERS.include?(m) })
|
|
65
|
+
return Result.blank(as: explicit, name:)
|
|
66
|
+
end
|
|
67
|
+
return Result.blank(as: :select, name:) if choices
|
|
68
|
+
return name_map_result(name) unless infer_from_model?
|
|
69
|
+
|
|
70
|
+
structural(model, name) || from_column(model, name) || name_map_result(name)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def infer_from_model?
|
|
74
|
+
PhlexForms.config.infer_from_model
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def name_map_result(name)
|
|
78
|
+
Result.blank(as: Builder::INPUT_TYPE_INFERENCE[name.to_sym] || :text, name:)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def structural(model, name)
|
|
82
|
+
return nil unless model
|
|
83
|
+
|
|
84
|
+
rich_text(model, name) || attachment(model, name) ||
|
|
85
|
+
enum(model, name) || association(model, name)
|
|
86
|
+
rescue StandardError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# ActionText defines a has_one :rich_text_<name> association.
|
|
91
|
+
def rich_text(model, name)
|
|
92
|
+
return nil unless reflect(model, :"rich_text_#{name}")
|
|
93
|
+
|
|
94
|
+
Result.blank(as: :rich_textarea, name:)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def attachment(model, name)
|
|
98
|
+
klass = model.class
|
|
99
|
+
return nil unless klass.respond_to?(:reflect_on_attachment)
|
|
100
|
+
|
|
101
|
+
reflection = klass.reflect_on_attachment(name)
|
|
102
|
+
return nil unless reflection
|
|
103
|
+
|
|
104
|
+
Result.blank(as: :file, name:).with(multiple: reflection.macro == :has_many_attached)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def enum(model, name)
|
|
108
|
+
klass = model.class
|
|
109
|
+
return nil unless klass.respond_to?(:defined_enums) && klass.defined_enums.key?(name.to_s)
|
|
110
|
+
|
|
111
|
+
pairs = klass.defined_enums[name.to_s].keys.map { |key| [key.humanize, key] }
|
|
112
|
+
Result.blank(as: :select, name:).with(choices: pairs)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A non-polymorphic belongs_to, matched by the association name (:country)
|
|
116
|
+
# or its foreign key (:country_id). The field name is rewritten to the
|
|
117
|
+
# foreign key; the label and required flag come from the association.
|
|
118
|
+
def association(model, name)
|
|
119
|
+
assoc_name = name.to_s.delete_suffix("_id").to_sym
|
|
120
|
+
reflection = reflect(model, name) || reflect(model, assoc_name)
|
|
121
|
+
return nil unless reflection && reflection.macro == :belongs_to && !reflection.polymorphic?
|
|
122
|
+
|
|
123
|
+
Result.blank(as: :select, name: reflection.foreign_key.to_sym).with(
|
|
124
|
+
label: association_label(model, reflection),
|
|
125
|
+
choices: -> { association_choices(reflection.klass) }, # lazy: only called without a choices: override
|
|
126
|
+
required: presence_validated?(model, reflection.name)
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def from_column(model, name)
|
|
131
|
+
type = column_type(model, name)
|
|
132
|
+
kind = COLUMN_TYPE_MAP[type&.type]
|
|
133
|
+
return nil unless kind
|
|
134
|
+
|
|
135
|
+
attrs = {}
|
|
136
|
+
if kind == :number && (step = step_for(type))
|
|
137
|
+
attrs[:step] = step
|
|
138
|
+
end
|
|
139
|
+
Result.blank(as: kind, name:).with(attributes: attrs)
|
|
140
|
+
rescue StandardError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def column_type(model, name)
|
|
145
|
+
return nil unless model
|
|
146
|
+
|
|
147
|
+
klass = model.class
|
|
148
|
+
if klass.respond_to?(:type_for_attribute)
|
|
149
|
+
klass.type_for_attribute(name.to_s)
|
|
150
|
+
elsif klass.respond_to?(:attribute_types)
|
|
151
|
+
klass.attribute_types[name.to_s]
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def step_for(type)
|
|
156
|
+
case type.type
|
|
157
|
+
when :integer then 1
|
|
158
|
+
when :decimal, :float
|
|
159
|
+
scale = type.respond_to?(:scale) ? type.scale : nil
|
|
160
|
+
scale ? (10**-scale).to_f : "any"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def association_label(model, reflection)
|
|
165
|
+
klass = model.class
|
|
166
|
+
return nil unless klass.respond_to?(:human_attribute_name)
|
|
167
|
+
|
|
168
|
+
klass.human_attribute_name(reflection.name)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def association_choices(klass)
|
|
172
|
+
label_method = nil
|
|
173
|
+
klass.all.map do |record|
|
|
174
|
+
label_method ||= LABEL_METHODS.find { |m| record.respond_to?(m) }
|
|
175
|
+
[record.public_send(label_method), record.id]
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def presence_validated?(model, attr)
|
|
180
|
+
klass = model.class
|
|
181
|
+
return false unless klass.respond_to?(:validators_on)
|
|
182
|
+
|
|
183
|
+
klass.validators_on(attr).any? do |v|
|
|
184
|
+
v.is_a?(ActiveModel::Validations::PresenceValidator) && !conditional?(v)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Length maximum -> maxlength (text-like kinds); numericality bounds ->
|
|
189
|
+
# min/max (number kind only; exclusive bounds map +/-1 only for
|
|
190
|
+
# only_integer, otherwise skipped).
|
|
191
|
+
def validator_attributes(model, name, kind)
|
|
192
|
+
return {} unless model.respond_to?(:class) && model.class.respond_to?(:validators_on)
|
|
193
|
+
|
|
194
|
+
attrs = {}
|
|
195
|
+
model.class.validators_on(name).each do |validator|
|
|
196
|
+
next if conditional?(validator)
|
|
197
|
+
|
|
198
|
+
case validator
|
|
199
|
+
when ActiveModel::Validations::LengthValidator
|
|
200
|
+
max = validator.options[:maximum]
|
|
201
|
+
attrs[:maxlength] = max if max && TEXT_LIKE.include?(kind)
|
|
202
|
+
when ActiveModel::Validations::NumericalityValidator
|
|
203
|
+
attrs.merge!(numericality_attributes(validator.options)) if kind == :number
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
attrs
|
|
207
|
+
rescue StandardError
|
|
208
|
+
{}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def numericality_attributes(options)
|
|
212
|
+
attrs = {}
|
|
213
|
+
integer = options[:only_integer]
|
|
214
|
+
if (min = options[:greater_than_or_equal_to])
|
|
215
|
+
attrs[:min] = min
|
|
216
|
+
elsif integer && (gt = options[:greater_than])
|
|
217
|
+
attrs[:min] = gt + 1
|
|
218
|
+
end
|
|
219
|
+
if (max = options[:less_than_or_equal_to])
|
|
220
|
+
attrs[:max] = max
|
|
221
|
+
elsif integer && (lt = options[:less_than])
|
|
222
|
+
attrs[:max] = lt - 1
|
|
223
|
+
end
|
|
224
|
+
attrs
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def reflect(model, name)
|
|
228
|
+
klass = model.class
|
|
229
|
+
return nil unless klass.respond_to?(:reflect_on_association)
|
|
230
|
+
|
|
231
|
+
klass.reflect_on_association(name)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def conditional?(validator)
|
|
235
|
+
validator.options.key?(:if) || validator.options.key?(:unless) || validator.options.key?(:on)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PhlexForms
|
|
4
|
+
# A theme maps component roles (:input, :select, :control, ...) to component
|
|
5
|
+
# classes. The binding contract — the leaf initializer signatures
|
|
6
|
+
# (*modifiers, name:, id:, value:, error:, required:, ...) — is the stable
|
|
7
|
+
# interface, so the same form class renders under any theme.
|
|
8
|
+
#
|
|
9
|
+
# Built-ins:
|
|
10
|
+
# Theme.daisy — the DaisyUI-delegating Forms::* leaves (default when the
|
|
11
|
+
# daisyui gem is loaded)
|
|
12
|
+
# Theme.plain — bare semantic HTML (Forms::Plain::*): variants accepted and
|
|
13
|
+
# ignored, zero styling classes, aria/data hooks only
|
|
14
|
+
#
|
|
15
|
+
# Select per form (`Form(model:, theme: :plain)`), per class
|
|
16
|
+
# (`form_options theme: :plain`), or globally
|
|
17
|
+
# (`PhlexForms.configure { |c| c.theme = :plain }`). Override single roles:
|
|
18
|
+
#
|
|
19
|
+
# PhlexForms::Theme.resolve(:plain).with(input: MyInput)
|
|
20
|
+
class Theme
|
|
21
|
+
def initialize(components)
|
|
22
|
+
@components = components.freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def [](role)
|
|
26
|
+
@components.fetch(role) do
|
|
27
|
+
raise KeyError, "unknown theme role #{role.inspect} (roles: #{@components.keys.join(', ')})"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def with(**overrides)
|
|
32
|
+
self.class.new(@components.merge(overrides))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
def resolve(value)
|
|
37
|
+
value = PhlexForms.config.theme if value.nil?
|
|
38
|
+
case value
|
|
39
|
+
when Theme then value
|
|
40
|
+
when :daisy then daisy
|
|
41
|
+
when :plain then plain
|
|
42
|
+
else
|
|
43
|
+
raise ArgumentError, "unknown theme #{value.inspect} (use :daisy, :plain, or a PhlexForms::Theme)"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def daisy
|
|
48
|
+
unless defined?(DaisyUI)
|
|
49
|
+
raise PhlexForms::FeatureUnavailable,
|
|
50
|
+
"the daisy theme requires the daisyui gem. Add `gem \"daisyui\"` to your " \
|
|
51
|
+
"Gemfile, or use the plain theme."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@daisy ||= new(
|
|
55
|
+
input: Forms::Input, select: Forms::Select, choices_select: Forms::ChoicesSelect,
|
|
56
|
+
textarea: Forms::Textarea, rich_textarea: Forms::RichTextarea,
|
|
57
|
+
checkbox: Forms::Checkbox, toggle: Forms::Toggle, radio: Forms::Radio,
|
|
58
|
+
file: Forms::FileInput, wrapped_input: Forms::WrappedInput,
|
|
59
|
+
control: Forms::FormControl, label: Forms::Label,
|
|
60
|
+
field_error: Forms::FieldError, field_hint: Forms::FieldHint,
|
|
61
|
+
submit: Forms::Submit, row: Forms::Row, group: Forms::Group
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# choices_select degrades to the native plain select (no choices.js) and
|
|
66
|
+
# rich_textarea to a plain textarea; toggle renders as a checkbox.
|
|
67
|
+
def plain
|
|
68
|
+
@plain ||= new(
|
|
69
|
+
input: Forms::Plain::Input, select: Forms::Plain::Select, choices_select: Forms::Plain::Select,
|
|
70
|
+
textarea: Forms::Plain::Textarea, rich_textarea: Forms::Plain::Textarea,
|
|
71
|
+
checkbox: Forms::Plain::Checkbox, toggle: Forms::Plain::Checkbox, radio: Forms::Plain::Radio,
|
|
72
|
+
file: Forms::Plain::FileInput, wrapped_input: Forms::Plain::WrappedInput,
|
|
73
|
+
control: Forms::Plain::Control, label: Forms::Plain::Label,
|
|
74
|
+
field_error: Forms::Plain::FieldError, field_hint: Forms::Plain::FieldHint,
|
|
75
|
+
submit: Forms::Plain::Submit, row: Forms::Plain::Row, group: Forms::Plain::Group
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
data/lib/phlex_forms/version.rb
CHANGED
data/lib/phlex_forms.rb
CHANGED
|
@@ -2,11 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
require "date" # Phlex's attribute serializer references Date; ensure it's loaded.
|
|
4
4
|
require "active_support/core_ext/object/blank" # blank?/present?/presence
|
|
5
|
+
require "active_support/core_ext/enumerable" # exclude?
|
|
6
|
+
require "active_support/core_ext/string" # underscore/pluralize/humanize (scope/url/label derivation)
|
|
5
7
|
require "phlex"
|
|
6
|
-
require "daisy_ui"
|
|
7
8
|
require "glyphs"
|
|
8
9
|
require "zeitwerk"
|
|
9
10
|
|
|
11
|
+
begin
|
|
12
|
+
require "daisy_ui"
|
|
13
|
+
rescue LoadError
|
|
14
|
+
# daisyui is a soft dependency: without it the Plain theme is the default.
|
|
15
|
+
# The daisy leaf components only reference DaisyUI inside view_template, so
|
|
16
|
+
# they load fine — they just must not be rendered (Theme.daisy raises).
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
begin
|
|
20
|
+
require "phlex/reactive"
|
|
21
|
+
rescue LoadError
|
|
22
|
+
# phlex-reactive is a soft dependency: without it the `live` server-truth
|
|
23
|
+
# validation macro raises a FeatureUnavailable pointing here, and the live
|
|
24
|
+
# files are excluded from autoloading below (they include
|
|
25
|
+
# Phlex::Reactive::Component at class level, so they must not load).
|
|
26
|
+
end
|
|
27
|
+
|
|
10
28
|
require_relative "phlex_forms/version"
|
|
11
29
|
|
|
12
30
|
# phlex-forms exposes its components under the `Forms::` namespace (Form, Input,
|
|
@@ -72,6 +90,13 @@ loader.ignore("#{__dir__}/phlex_forms/rubocop.rb") # cop entry point (RuboCop::
|
|
|
72
90
|
# Rails), so it must not be autoloaded/eager-loaded by Zeitwerk.
|
|
73
91
|
loader.ignore("#{__dir__}/phlex_forms/engine.rb")
|
|
74
92
|
|
|
93
|
+
# The live-validation layer includes Phlex::Reactive::Component at class level,
|
|
94
|
+
# so it can only load when the phlex-reactive soft dependency is present.
|
|
95
|
+
unless defined?(Phlex::Reactive)
|
|
96
|
+
loader.ignore("#{__dir__}/forms/live.rb")
|
|
97
|
+
loader.ignore("#{__dir__}/forms/live")
|
|
98
|
+
end
|
|
99
|
+
|
|
75
100
|
loader.setup
|
|
76
101
|
|
|
77
102
|
# Optional Rails integration (Stimulus controllers via importmap/assets, and
|