plutonium 0.25.1 → 0.26.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.
@@ -0,0 +1,119 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="key-value-store"
4
+ export default class extends Controller {
5
+ static targets = ["container", "pair", "template", "addButton", "keyInput", "valueInput"]
6
+ static values = { limit: Number }
7
+
8
+ connect() {
9
+ this.updateIndices()
10
+ this.updateAddButtonState()
11
+ }
12
+
13
+ addPair(event) {
14
+ event.preventDefault()
15
+
16
+ if (this.pairTargets.length >= this.limitValue) {
17
+ return
18
+ }
19
+
20
+ const template = this.templateTarget
21
+ const newPair = template.content.cloneNode(true)
22
+ const index = this.pairTargets.length
23
+
24
+ // Update the template placeholders with actual indices
25
+ this.updatePairIndices(newPair, index)
26
+
27
+ this.containerTarget.appendChild(newPair)
28
+ this.updateIndices()
29
+ this.updateAddButtonState()
30
+
31
+ // Focus on the key input of the new pair
32
+ const newKeyInput = this.containerTarget.lastElementChild.querySelector('[data-key-value-store-target="keyInput"]')
33
+ if (newKeyInput) {
34
+ newKeyInput.focus()
35
+ }
36
+ }
37
+
38
+ removePair(event) {
39
+ event.preventDefault()
40
+
41
+ const pair = event.target.closest('[data-key-value-store-target="pair"]')
42
+ if (pair) {
43
+ pair.remove()
44
+ this.updateIndices()
45
+ this.updateAddButtonState()
46
+ }
47
+ }
48
+
49
+ updateIndices() {
50
+ this.pairTargets.forEach((pair, index) => {
51
+ const keyInput = pair.querySelector('[data-key-value-store-target="keyInput"]')
52
+ const valueInput = pair.querySelector('[data-key-value-store-target="valueInput"]')
53
+
54
+ if (keyInput) {
55
+ // Update name attribute
56
+ keyInput.name = keyInput.name.replace(/\[\d+\]/, `[${index}]`)
57
+ // Update id attribute for Turbo compatibility
58
+ keyInput.id = keyInput.id.replace(/_\d+_/, `_${index}_`)
59
+ }
60
+ if (valueInput) {
61
+ // Update name attribute
62
+ valueInput.name = valueInput.name.replace(/\[\d+\]/, `[${index}]`)
63
+ // Update id attribute for Turbo compatibility
64
+ valueInput.id = valueInput.id.replace(/_\d+_/, `_${index}_`)
65
+ }
66
+ })
67
+ }
68
+
69
+ updatePairIndices(element, index) {
70
+ const inputs = element.querySelectorAll('input')
71
+ inputs.forEach(input => {
72
+ if (input.name) {
73
+ input.name = input.name.replace('__INDEX__', index)
74
+ }
75
+ if (input.id) {
76
+ input.id = input.id.replace('___INDEX___', `_${index}_`)
77
+ }
78
+ })
79
+ }
80
+
81
+ updateAddButtonState() {
82
+ const addButton = this.addButtonTarget
83
+ if (this.pairTargets.length >= this.limitValue) {
84
+ addButton.disabled = true
85
+ addButton.classList.add('opacity-50', 'cursor-not-allowed')
86
+ } else {
87
+ addButton.disabled = false
88
+ addButton.classList.remove('opacity-50', 'cursor-not-allowed')
89
+ }
90
+ }
91
+
92
+ // Serialize the current key-value pairs to JSON
93
+ toJSON() {
94
+ const pairs = {}
95
+ this.pairTargets.forEach(pair => {
96
+ const keyInput = pair.querySelector('[data-key-value-store-target="keyInput"]')
97
+ const valueInput = pair.querySelector('[data-key-value-store-target="valueInput"]')
98
+
99
+ if (keyInput && valueInput && keyInput.value.trim()) {
100
+ pairs[keyInput.value.trim()] = valueInput.value
101
+ }
102
+ })
103
+ return JSON.stringify(pairs)
104
+ }
105
+
106
+ // Get the current key-value pairs as an object
107
+ toObject() {
108
+ const pairs = {}
109
+ this.pairTargets.forEach(pair => {
110
+ const keyInput = pair.querySelector('[data-key-value-store-target="keyInput"]')
111
+ const valueInput = pair.querySelector('[data-key-value-store-target="valueInput"]')
112
+
113
+ if (keyInput && valueInput && keyInput.value.trim()) {
114
+ pairs[keyInput.value.trim()] = valueInput.value
115
+ }
116
+ })
117
+ return pairs
118
+ }
119
+ }
@@ -371,7 +371,7 @@ class PostDefinition < Plutonium::Resource::Definition
371
371
 
372
372
  # Add filters to the sidebar
373
373
  filter :published, with: ->(scope, value) { value ? scope.where.not(published_at: nil) : scope.where(published_at: nil) }, as: :boolean
374
- filter :user, as: :select, collection: -> { User.pluck(:name, :id) }
374
+ filter :user, as: :select, choices: User.pluck(:name, :id)
375
375
 
376
376
  # Define named scopes that appear as buttons
377
377
  scope :all
@@ -71,7 +71,7 @@ class PostDefinition < Plutonium::Resource::Definition
71
71
  # Only override when you need custom input behavior:
72
72
  input :content, as: :rich_text # Override text -> rich_text
73
73
  input :title, placeholder: "Enter title" # Add placeholder
74
- input :category, as: :select, collection: %w[Tech Business] # Add options
74
+ input :category, as: :select, choices: %w[Tech Business] # Add options
75
75
  input :published_at, as: :date # Override datetime -> date only
76
76
  end
77
77
  ```
@@ -158,8 +158,8 @@ end
158
158
  ```ruby
159
159
  class PostDefinition < Plutonium::Resource::Definition
160
160
  # Provide options for select inputs
161
- input :category, as: :select, collection: %w[Tech Business Lifestyle]
162
- input :author, as: :select, collection: -> { User.active.pluck(:name, :id) }
161
+ input :category, as: :select, choices: %w[Tech Business Lifestyle]
162
+ input :author, as: :select, choices: -> { User.active.pluck(:name, :id) }
163
163
  end
164
164
  ```
165
165
 
@@ -177,7 +177,7 @@ class PostDefinition < Plutonium::Resource::Definition
177
177
 
178
178
  # Use `pre_submit` to create dynamic forms where inputs appear based on other inputs.
179
179
  input :send_notifications, as: :boolean, pre_submit: true
180
- input :notification_channel, as: :select, collection: %w[Email SMS],
180
+ input :notification_channel, as: :select, choices: %w[Email SMS],
181
181
  condition: -> { object.send_notifications? }
182
182
 
183
183
  # Show debug fields only in development
@@ -280,6 +280,7 @@ The block syntax offers more control over rendering, allowing for custom compone
280
280
  - Building complex layouts with multiple components or custom HTML (for `display` only).
281
281
  - You need conditional logic to determine which component to render.
282
282
  - You need to call specific form builder methods with custom logic (for `input`).
283
+ - **You need dynamic choices for select inputs** (since `choices:` option only accepts static arrays).
283
284
 
284
285
  ::: code-group
285
286
  ```ruby [Custom Display Components]
@@ -302,6 +303,29 @@ input :birth_date do |f|
302
303
  end
303
304
  end
304
305
  ```
306
+ ```ruby [Dynamic Choices for Select Inputs]
307
+ # Dynamic choices based on object state
308
+ input :widget_type do |f|
309
+ choices = case object.question_type
310
+ when nil, ""
311
+ []
312
+ when "text"
313
+ ["input", "textarea"]
314
+ when "choice"
315
+ ["radio", "select", "checkbox"]
316
+ when "scale"
317
+ ["slider", "radio_scale", "select_scale"]
318
+ when "date"
319
+ ["date_picker", "datetime_picker"]
320
+ when "boolean"
321
+ ["checkbox", "toggle", "yes_no"]
322
+ else
323
+ Question::WIDGET_TYPES
324
+ end
325
+
326
+ f.select_tag choices: choices
327
+ end
328
+ ```
305
329
  ```ruby [Conditional Rendering]
306
330
  # Conditional display based on value
307
331
  display :metrics do |field|
@@ -829,32 +853,43 @@ display :content,
829
853
  condition: -> { current_user.can_see_content? }
830
854
  ```
831
855
 
832
- ### Collection Options (for selects)
856
+ ### Choices Options (for selects)
833
857
  ```ruby
834
- input :category, as: :select, collection: %w[Tech Business Lifestyle]
835
- input :author, as: :select, collection: -> { User.active.pluck(:name, :id) }
858
+ # Static choices
859
+ input :category, as: :select, choices: %w[Tech Business Lifestyle]
836
860
 
837
- # Collection procs are executed in the form rendering context
838
- # and have access to current_user and other helpers:
839
- input :team_members, as: :select, collection: -> {
840
- current_user.organization.users.active.pluck(:name, :id)
841
- }
861
+ # Dynamic choices require block form
862
+ input :author do |f|
863
+ choices = User.active.pluck(:name, :id)
864
+ f.select_tag choices: choices
865
+ end
842
866
 
843
- # You can also access the form object being edited:
844
- input :related_posts, as: :select, collection: -> {
845
- Post.where.not(id: object.id).published.pluck(:title, :id) if object.persisted?
846
- }
867
+ # Dynamic choices with access to context
868
+ input :team_members do |f|
869
+ choices = current_user.organization.users.active.pluck(:name, :id)
870
+ f.select_tag choices: choices
871
+ end
872
+
873
+ # Dynamic choices based on object state
874
+ input :related_posts do |f|
875
+ choices = if object.persisted?
876
+ Post.where.not(id: object.id).published.pluck(:title, :id)
877
+ else
878
+ []
879
+ end
880
+ f.select_tag choices: choices
881
+ end
847
882
  ```
848
883
 
849
- ::: tip Collection Context
850
- Collection procs are evaluated in the form rendering context, which means they have access to:
884
+ ::: tip Dynamic Choices Context
885
+ When using block forms for dynamic choices, you have access to:
851
886
  - `current_user` - The authenticated user
852
887
  - `current_parent` - Parent record for nested resources
853
888
  - `object` - The record being edited (in edit forms)
854
889
  - `request` and `params` - Request information
855
890
  - All helper methods available in the form context
856
891
 
857
- This is the same context as `condition` procs, allowing for dynamic, user-specific collections.
892
+ Block forms are required for dynamic choices since the `choices:` option only accepts static arrays.
858
893
  :::
859
894
 
860
895
  ### File Upload Options
@@ -868,7 +903,7 @@ input :documents, as: :file, multiple: true,
868
903
  ## Dynamic Configuration & Policies
869
904
 
870
905
  ::: danger IMPORTANT
871
- Definitions are instantiated outside the controller context, which means **`current_user` and other controller methods are NOT available** within the definition file itself. However, `condition` and `collection` procs ARE evaluated in the rendering context where `current_user` and the record (`object`) are available.
906
+ Definitions are instantiated outside the controller context, which means **`current_user` and other controller methods are NOT available** within the definition file itself. However, `condition` procs and input blocks ARE evaluated in the rendering context where `current_user` and the record (`object`) are available.
872
907
  :::
873
908
 
874
909
  The `condition` option configures **if an input is rendered**. It does not control if a field's *value* is accessible. For that, you must use policies.
@@ -65,7 +65,7 @@ column :published_at, as: :datetime
65
65
  column :is_active, as: :boolean
66
66
  column :priority, as: :badge
67
67
  column :profile_picture, as: :attachment
68
- column :metadata, as: :json
68
+ column :metadata, as: :key_value_store
69
69
  ```
70
70
 
71
71
  ### Alignment
@@ -90,7 +90,7 @@ class PostDefinition < Plutonium::Resource::Definition
90
90
 
91
91
  # Only declare fields when you want to override defaults:
92
92
  field :content, as: :rich_text # Override text → rich_text
93
- field :status, as: :select, collection: %w[draft published archived]
93
+ field :status, as: :select, choices: %w[draft published archived]
94
94
 
95
95
  # Display customization (show/index pages)
96
96
  display :title, as: :string
@@ -119,7 +119,7 @@ class PostDefinition < Plutonium::Resource::Definition
119
119
  # Input configuration (new/edit forms)
120
120
  input :title, placeholder: "Enter post title" # No need for as: :string (auto-detected)
121
121
  input :content, as: :rich_text
122
- input :category, as: :select, collection: %w[Tech Business Lifestyle]
122
+ input :category, as: :select, choices: %w[Tech Business Lifestyle]
123
123
 
124
124
  # New: Custom component classes
125
125
  input :color_picker, as: ColorPickerComponent
@@ -239,19 +239,32 @@ display :content,
239
239
  condition: -> { Rails.env.development? } # Cosmetic condition only
240
240
  ```
241
241
 
242
- #### Collection Options (for selects)
242
+ #### Choices Options (for selects)
243
243
  ```ruby
244
- input :category, as: :select, collection: %w[Tech Business Lifestyle]
245
- input :author, as: :select, collection: -> { User.active.pluck(:name, :id) }
246
-
247
- # Collection procs are executed in form rendering context with access to:
248
- # current_user, current_parent, object, request, params, and helpers
249
- input :team_members, as: :select, collection: -> {
250
- current_user.organization.users.active.pluck(:name, :id)
251
- }
252
- input :related_posts, as: :select, collection: -> {
253
- Post.where.not(id: object.id).published.pluck(:title, :id) if object.persisted?
254
- }
244
+ # Static choices
245
+ input :category, as: :select, choices: %w[Tech Business Lifestyle]
246
+
247
+ # Dynamic choices require block form
248
+ input :author do |f|
249
+ choices = User.active.pluck(:name, :id)
250
+ f.select_tag choices: choices
251
+ end
252
+
253
+ # Dynamic choices with access to context
254
+ input :team_members do |f|
255
+ choices = current_user.organization.users.active.pluck(:name, :id)
256
+ f.select_tag choices: choices
257
+ end
258
+
259
+ # Dynamic choices based on object state
260
+ input :related_posts do |f|
261
+ choices = if object.persisted?
262
+ Post.where.not(id: object.id).published.pluck(:title, :id)
263
+ else
264
+ []
265
+ end
266
+ f.select_tag choices: choices
267
+ end
255
268
  ```
256
269
 
257
270
  #### File Upload Options
@@ -79,13 +79,13 @@ module Plutonium
79
79
  url_args[:id] = element.to_param unless resource_route_config[:route_type] == :resource
80
80
  url_args[:action] ||= :show
81
81
  else
82
- url_args[element.model_name.singular_route_key.to_sym] = element.to_param
82
+ url_args[element.model_name.singular.to_sym] = element.to_param
83
83
  end
84
84
  end
85
85
  end
86
86
  url_args[:controller] = "/#{controller_chain.join("::").underscore}"
87
87
 
88
- url_args[:"#{parent.model_name.singular_route_key}_id"] = parent.to_param if parent.present?
88
+ url_args[:"#{parent.model_name.singular}_id"] = parent.to_param if parent.present?
89
89
  if scoped_to_entity? && scoped_entity_strategy == :path
90
90
  url_args[scoped_entity_param_key] = current_scoped_entity
91
91
  end
@@ -43,6 +43,11 @@ module Plutonium
43
43
  current_engine.scoped_entity_param_key
44
44
  end
45
45
 
46
+ def scoped_entity_route_key
47
+ ensure_legal_entity_scoping_method_access!(__method__)
48
+ current_engine.scoped_entity_route_key
49
+ end
50
+
46
51
  # Returns the class of the scoped entity.
47
52
  #
48
53
  # @return [Class] the scoped entity class
@@ -5,12 +5,13 @@ module Plutonium
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  class_methods do
8
- attr_reader :scoped_entity_class, :scoped_entity_strategy, :scoped_entity_param_key
8
+ attr_reader :scoped_entity_class, :scoped_entity_strategy, :scoped_entity_param_key, :scoped_entity_route_key
9
9
 
10
- def scope_to_entity(entity_class, strategy: :path, param_key: nil)
10
+ def scope_to_entity(entity_class, strategy: :path, param_key: nil, route_key: nil)
11
11
  @scoped_entity_class = entity_class
12
12
  @scoped_entity_strategy = strategy
13
13
  @scoped_entity_param_key = param_key || entity_class.model_name.singular_route_key.to_sym
14
+ @scoped_entity_route_key = route_key || entity_class.model_name.singular.to_sym
14
15
  end
15
16
 
16
17
  def scoped_to_entity?
@@ -12,7 +12,18 @@ module Plutonium
12
12
  # @param controller [ActionController::Base] The controller instance.
13
13
  # @return [void]
14
14
  def execute(controller)
15
- controller.redirect_to(*@args, **@options)
15
+ controller.instance_eval do
16
+ url = url_for(*@args)
17
+
18
+ format.any { redirect_to(url, **@options) }
19
+ if helpers.current_turbo_frame == "remote_modal"
20
+ format.turbo_stream do
21
+ render turbo_stream: [
22
+ helpers.turbo_stream_redirect(url)
23
+ ]
24
+ end
25
+ end
26
+ end
16
27
  end
17
28
  end
18
29
  end
@@ -44,7 +44,7 @@ module Plutonium
44
44
  def route_key_lookup
45
45
  freeze
46
46
  resources.to_h do |resource|
47
- [resource.model_name.singular_route_key.to_sym, resource]
47
+ [resource.model_name.singular.to_sym, resource]
48
48
  end
49
49
  end
50
50
  # memoize_unless_reloading :route_key_lookup
@@ -35,6 +35,10 @@ module Plutonium
35
35
  alias_method :file_tag, :uppy_tag
36
36
  alias_method :attachment_tag, :uppy_tag
37
37
 
38
+ def key_value_store_tag(**, &)
39
+ create_component(Components::KeyValueStore, :key_value_store, **, &)
40
+ end
41
+
38
42
  def secure_association_tag(**attributes, &)
39
43
  attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select") # TODO: put this behind a config
40
44
  create_component(Components::SecureAssociation, :association, **attributes, &)
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ class KeyValueStore < Phlexi::Form::Components::Base
8
+ include Phlexi::Form::Components::Concerns::HandlesInput
9
+
10
+ DEFAULT_LIMIT = 10
11
+
12
+ def view_template
13
+ div(**container_attributes) do
14
+ render_key_value_pairs
15
+ render_add_button
16
+ render_template
17
+ end
18
+ end
19
+
20
+ protected
21
+
22
+ def build_attributes
23
+ super
24
+ attributes[:class] = [attributes[:class], "key-value-store"].compact.join(" ")
25
+ set_data_attributes
26
+ end
27
+
28
+ private
29
+
30
+ def container_attributes
31
+ {
32
+ id: attributes[:id],
33
+ class: attributes[:class],
34
+ data: {
35
+ controller: "key-value-store",
36
+ key_value_store_limit_value: limit
37
+ }
38
+ }
39
+ end
40
+
41
+ def set_data_attributes
42
+ attributes[:data] ||= {}
43
+ attributes[:data][:controller] = "key-value-store"
44
+ attributes[:data][:key_value_store_limit_value] = limit
45
+ end
46
+
47
+ def render_header
48
+ div(class: "key-value-store-header") do
49
+ if attributes[:label]
50
+ h3(class: "text-lg font-semibold text-gray-900 dark:text-white") do
51
+ plain attributes[:label]
52
+ end
53
+ end
54
+ if attributes[:description]
55
+ p(class: "text-sm text-gray-500 dark:text-gray-400") do
56
+ plain attributes[:description]
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ def render_key_value_pairs
63
+ div(class: "key-value-pairs space-y-2", data_key_value_store_target: "container") do
64
+ pairs.each_with_index do |(key, value), index|
65
+ render_key_value_pair(key, value, index)
66
+ end
67
+ end
68
+ end
69
+
70
+ def render_key_value_pair(key, value, index)
71
+ div(
72
+ class: "key-value-pair flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded",
73
+ data_key_value_store_target: "pair"
74
+ ) do
75
+ # Key input
76
+ input(
77
+ type: :text,
78
+ placeholder: "Key",
79
+ value: key,
80
+ name: "#{field_name}[#{index}][key]",
81
+ id: "#{field.dom.id}_#{index}_key",
82
+ class: "flex-1 px-3 py-1 text-sm border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white",
83
+ data_key_value_store_target: "keyInput"
84
+ )
85
+
86
+ # Value input
87
+ input(
88
+ type: :text,
89
+ placeholder: "Value",
90
+ value: value,
91
+ name: "#{field_name}[#{index}][value]",
92
+ id: "#{field.dom.id}_#{index}_value",
93
+ class: "flex-1 px-3 py-1 text-sm border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white",
94
+ data_key_value_store_target: "valueInput"
95
+ )
96
+
97
+ # Remove button
98
+ button(
99
+ type: :button,
100
+ class: "px-2 py-1 text-red-600 hover:text-red-800 focus:outline-none",
101
+ data_action: "key-value-store#removePair"
102
+ ) do
103
+ plain "×"
104
+ end
105
+ end
106
+ end
107
+
108
+ def render_add_button
109
+ div(class: "key-value-store-actions mt-2") do
110
+ button(
111
+ type: :button,
112
+ id: "#{field.dom.id}_add_button",
113
+ class: "inline-flex items-center px-3 py-1 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-blue-900 dark:text-blue-300 dark:border-blue-700 dark:hover:bg-blue-800",
114
+ data: {
115
+ action: "key-value-store#addPair",
116
+ key_value_store_target: "addButton"
117
+ }
118
+ ) do
119
+ plain "+ Add Pair"
120
+ end
121
+ end
122
+ end
123
+
124
+ def render_template
125
+ template(data_key_value_store_target: "template") do
126
+ div(
127
+ class: "key-value-pair flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded",
128
+ data_key_value_store_target: "pair"
129
+ ) do
130
+ input(
131
+ type: :text,
132
+ placeholder: "Key",
133
+ name: "#{field_name}[__INDEX__][key]",
134
+ id: "#{field.dom.id}___INDEX___key",
135
+ class: "flex-1 px-3 py-1 text-sm border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white",
136
+ data_key_value_store_target: "keyInput"
137
+ )
138
+
139
+ input(
140
+ type: :text,
141
+ placeholder: "Value",
142
+ name: "#{field_name}[__INDEX__][value]",
143
+ id: "#{field.dom.id}___INDEX___value",
144
+ class: "flex-1 px-3 py-1 text-sm border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white",
145
+ data_key_value_store_target: "valueInput"
146
+ )
147
+
148
+ button(
149
+ type: :button,
150
+ class: "px-2 py-1 text-red-600 hover:text-red-800 focus:outline-none",
151
+ data_action: "key-value-store#removePair"
152
+ ) do
153
+ plain "×"
154
+ end
155
+ end
156
+ end
157
+ end
158
+
159
+ def pairs
160
+ @pairs ||= normalize_value_to_pairs(field.value)
161
+ end
162
+
163
+ def normalize_value_to_pairs(value)
164
+ case value
165
+ when Hash
166
+ # Convert hash to array of [key, value] pairs
167
+ value.to_a
168
+ when String
169
+ parse_json_string(value)
170
+ else
171
+ []
172
+ end
173
+ end
174
+
175
+ def parse_json_string(value)
176
+ return [] if value.blank?
177
+
178
+ begin
179
+ parsed = JSON.parse(value)
180
+ case parsed
181
+ when Hash
182
+ parsed.to_a
183
+ else
184
+ []
185
+ end
186
+ rescue JSON::ParserError
187
+ []
188
+ end
189
+ end
190
+
191
+ def field_name
192
+ field.dom.name
193
+ end
194
+
195
+ def limit
196
+ attributes.fetch(:limit, DEFAULT_LIMIT)
197
+ end
198
+
199
+ # Override from ExtractsInput concern to normalize form parameters
200
+ def normalize_input(input_value)
201
+ case input_value
202
+ when Hash
203
+ if input_value.keys.all? { |k| k.to_s.match?(/^\d+$/) }
204
+ # Handle indexed form params: {"0" => {"key" => "foo", "value" => "bar"}}
205
+ process_indexed_params(input_value)
206
+ else
207
+ # Handle direct hash params
208
+ input_value.reject { |k, v| k.blank? || (v.blank? && v != false) }
209
+ end
210
+ when nil
211
+ {}
212
+ end
213
+ end
214
+
215
+ private
216
+
217
+ # Process indexed form parameters into a hash
218
+ def process_indexed_params(params)
219
+ params.values.each_with_object({}) do |pair, hash|
220
+ next unless pair.is_a?(Hash)
221
+
222
+ key = pair["key"] || pair[:key]
223
+ value = pair["value"] || pair[:value]
224
+
225
+ if key.present?
226
+ hash[key] = value
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end