Class: Forms::Field

Inherits:
Object
  • Object
show all
Defined in:
lib/forms/field.rb

Direct Known Subclasses

Live::Field

Constant Summary collapse

TOKEN_JOINED_DATA_KEYS =

Data keys whose values are Stimulus token lists — merged by joining, not replacing, so validation controllers and live triggers coexist.

%i[controller action].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name:, model:, scope:, errors:, form:, error_name: nil) ⇒ Field

error_name: where errors live when it differs from the input's name — an inferred belongs_to select is named :country_id but Rails attaches its errors to :country.



19
20
21
22
23
24
25
26
# File 'lib/forms/field.rb', line 19

def initialize(name:, model:, scope:, errors:, form:, error_name: nil)
  @name = name
  @error_name = error_name || name
  @model = model
  @scope = scope
  @errors = errors
  @form = form
end

Instance Attribute Details

#errorsObject (readonly)

Returns the value of attribute errors.



14
15
16
# File 'lib/forms/field.rb', line 14

def errors
  @errors
end

#modelObject (readonly)

Returns the value of attribute model.



14
15
16
# File 'lib/forms/field.rb', line 14

def model
  @model
end

#nameObject (readonly)

Returns the value of attribute name.



14
15
16
# File 'lib/forms/field.rb', line 14

def name
  @name
end

#scopeObject (readonly)

Returns the value of attribute scope.



14
15
16
# File 'lib/forms/field.rb', line 14

def scope
  @scope
end

Instance Method Details

#apply_validations(options) ⇒ Object

Merge a per-call validate: override into the field's option hash. Strips :validate and, when rules apply, merges the Stimulus data into data:. Falls back to the form-level introspector when :validate is absent.

validate: false           → opt this field out
validate: true            → the form-level introspector
validate: { length: {…} } → explicit inline rules


225
226
227
228
229
230
231
232
# File 'lib/forms/field.rb', line 225

def apply_validations(options)
  return consume_validate(options) if options.key?(:validate)

  data = form_introspector.data_attributes_for(@name)
  return options if data.empty?

  options.merge(data: merge_data(options[:data], data))
end

#checkbox(*modifiers) ⇒ Object Also known as: check_box



71
72
73
# File 'lib/forms/field.rb', line 71

def checkbox(*modifiers, **)
  theme[:checkbox].new(*modifiers, checked: field_value, **field_attributes.except(:value), **)
end

#checkbox_group(collection, value: :id, label: nil, item_label: nil) ⇒ Object

A model-bound checkbox group over a collection. Shares one array-valued field name (scope[name][]) and derives the checked set from the model's current value, matched by each item's resolved value: (issue #9).

field.checkbox_group(Tag.all, value: :id, label: ->(t) { t.name })

value: is a method name (Symbol) or a proc taking the item -> its submitted value. The per-item visible text comes from item_label: (Symbol/Proc/String) if given, else label:; when NEITHER is given each item is labelled by the first of name/title/label/to_s it responds to (the same LABEL_METHODS chain Inference uses for association choices) — so a plain f.field(:tags, as: :checkbox_group, label: "Tags") shows readable item text without an explicit accessor.

item_label: exists so the f.field path can pass a visible group heading as label: (consumed by the Control) AND still customize the per-item text here — the two no longer collide. item_label: is consumed here; it never leaks to the group div.



138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/forms/field.rb', line 138

def checkbox_group(collection, value: :id, label: nil, item_label: nil, **)
  text = item_label || label
  # The model's current value is already the raw values (e.g. record.tag_ids
  # => [1, 3]), so compare against them directly — don't re-resolve value:.
  selected = Array(field_value)
  opts = Array(collection).map do |item|
    item_value = resolve_item(item, value)
    {
      value: item_value,
      label: text ? resolve_item(item, text) : infer_item_label(item),
      checked: selected.include?(item_value),
      id: "#{field_id}_#{item_value}"
    }
  end
  theme[:checkbox_group].new(name: "#{field_name}[]", id: field_id, options: opts, error: invalid?, **)
end

#choices_select(choices = nil, *modifiers, **options) ⇒ Object

Enhanced select: choices.js-backed when searchable, native otherwise.



99
100
101
102
103
104
105
106
107
# File 'lib/forms/field.rb', line 99

def choices_select(choices = nil, *modifiers, **options)
  searchable = options.delete(:searchable) { false }
  opts = select_options(options)
  if searchable
    theme[:choices_select].new(*modifiers, choices:, selected: field_value, searchable: true, **opts)
  else
    theme[:select].new(*modifiers, choices:, selected: field_value, **opts)
  end
end

#control(label: nil, hint: nil, required: false) ⇒ Object



159
160
161
# File 'lib/forms/field.rb', line 159

def control(label: nil, hint: nil, required: false, **, &)
  theme[:control].new(label:, hint:, error: field_error_message, for: field_id, required:, **, &)
end

#field_idObject



196
197
198
199
200
201
202
# File 'lib/forms/field.rb', line 196

def field_id
  if @scope
    "#{@scope.tr('[', '_').delete(']')}_#{@name}"
  else
    @name.to_s
  end
end

#field_labelObject

Humanized label text: the model's human_attribute_name when available.



166
167
168
169
170
171
172
# File 'lib/forms/field.rb', line 166

def field_label
  if @model.respond_to?(:class) && @model.class.respond_to?(:human_attribute_name)
    @model.class.human_attribute_name(@name)
  else
    @name.to_s.tr("_", " ").capitalize
  end
end

#field_nameObject



192
193
194
# File 'lib/forms/field.rb', line 192

def field_name
  @scope ? "#{@scope}[#{@name}]" : @name.to_s
end

#field_valueObject

Ransack-safe getter: dispatches through method_missing consumers (e.g. Ransack::Search predicate getters) but only swallows the direct dispatch miss.



206
207
208
209
210
211
212
213
214
# File 'lib/forms/field.rb', line 206

def field_value
  return nil unless @model

  @model.public_send(@name)
rescue NoMethodError => e
  raise unless e.receiver.equal?(@model) && e.name == @name

  nil
end

#file(*modifiers) ⇒ Object



67
68
69
# File 'lib/forms/field.rb', line 67

def file(*modifiers, **)
  theme[:file].new(*modifiers, **field_attributes.except(:value), **)
end

#hiddenObject

Bare , never the styled :input leaf — daisyui's .input beats WebKit's non-!important input[type=hidden] { display: none } and turns the field into a focusable phantom tab stop in Safari. Built from the explicit accessors (not field_attributes) so the meaningless error: invalid? is never introduced.



57
58
59
# File 'lib/forms/field.rb', line 57

def hidden(**)
  theme[:hidden].new(name: field_name, id: field_id, value: field_value, **)
end

#input(*modifiers, type: :text) ⇒ Object

--- leaf component builders (return component instances to render) --- Component classes resolve through the form's theme (see PhlexForms::Theme), so the same field renders daisy or plain.



32
33
34
35
36
37
38
39
40
# File 'lib/forms/field.rb', line 32

def input(*modifiers, type: :text, **)
  # type: :hidden reroutes to #hidden so EVERY path to a hidden field lands on
  # the bare leaf — `f.Input(:token, :hidden)` and `f.Input(:token, type:
  # :hidden)` would otherwise still emit the styled `input w-full`. Positional
  # modifiers are dropped with it: a hidden field has no visual variants.
  return hidden(**) if type.to_s == "hidden"

  theme[:input].new(*modifiers, type:, **field_attributes, **)
end

#invalid?Boolean

Returns:

  • (Boolean)


186
187
188
189
190
# File 'lib/forms/field.rb', line 186

def invalid?
  return false unless @errors

  @errors.include?(@name) || @errors.include?(@error_name)
end

#label(text = nil, *modifiers, &block) ⇒ Object



155
156
157
# File 'lib/forms/field.rb', line 155

def label(text = nil, *modifiers, **, &block)
  theme[:label].new(*modifiers, text: text || (block ? nil : field_label), for: field_id, **, &block)
end

#radio(value, *modifiers, **options) ⇒ Object Also known as: radio_button



80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/forms/field.rb', line 80

def radio(value, *modifiers, **options)
  # field_attributes carries value: field_value (the model's CURRENT value).
  # Drop it here so it can't clobber this radio's own positional value —
  # otherwise every radio in the group renders the model's value (issue #13).
  attrs = field_attributes.except(:value).merge(options)
  theme[:radio].new(
    *modifiers,
    value:,
    checked: field_value == value,
    **attrs.merge(id: "#{field_id}_#{value}")
  )
end

#required?Boolean

Inferred from the model's presence validators (ActiveModel). False when the model doesn't expose validators.

Returns:

  • (Boolean)


176
177
178
179
180
181
182
183
184
# File 'lib/forms/field.rb', line 176

def required?
  return false unless @model.respond_to?(:class) && @model.class.respond_to?(:validators_on)

  @model.class.validators_on(@name).any? do |v|
    v.is_a?(ActiveModel::Validations::PresenceValidator) && !conditional?(v)
  end
rescue StandardError
  false
end

#rich_textarea(*modifiers) ⇒ Object Also known as: rich_text_area



47
48
49
# File 'lib/forms/field.rb', line 47

def rich_textarea(*modifiers, **)
  theme[:rich_textarea].new(*modifiers, name: field_name, id: field_id, value: field_value, **)
end

#select(choices = nil, **options) ⇒ Object



94
95
96
# File 'lib/forms/field.rb', line 94

def select(choices = nil, **options)
  theme[:select].new(choices:, selected: field_value, **select_options(options))
end

#tag_field(*modifiers, suggestions: []) ⇒ Object

A model-bound tag/chip input (phlex-reactive client-only primitives). suggestions: an Array of tags or a Hash of tag => haystack (synonyms the filter matches). Submits one comma-joined param under the field name.



112
113
114
115
116
117
118
# File 'lib/forms/field.rb', line 112

def tag_field(*modifiers, suggestions: [], **)
  theme[:tag_field].new(
    *modifiers,
    name: field_name, id: field_id, value: field_value,
    suggestions:, error: invalid?, **
  )
end

#textarea(*modifiers) ⇒ Object Also known as: text_area



42
43
44
# File 'lib/forms/field.rb', line 42

def textarea(*modifiers, **)
  theme[:textarea].new(*modifiers, **field_attributes, **)
end

#toggle(*modifiers) ⇒ Object



76
77
78
# File 'lib/forms/field.rb', line 76

def toggle(*modifiers, **)
  theme[:toggle].new(*modifiers, checked: field_value, **field_attributes.except(:value), **)
end

#wrapped_input(*modifiers, type: :text) ⇒ Object

daisyui v5 "icon/text inside the field" wrapper. The block renders the leading content (icon, prefix); the bare input is wired to this field.



63
64
65
# File 'lib/forms/field.rb', line 63

def wrapped_input(*modifiers, type: :text, **, &)
  theme[:wrapped_input].new(*modifiers, type:, **field_attributes.except(:error), error: invalid?, **, &)
end