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.
- checksums.yaml +4 -4
- data/app/assets/plutonium.css +2 -2
- data/app/assets/plutonium.js +5577 -95
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +76 -45
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/javascript/controllers/key_value_store_controller.js +119 -0
- data/docs/guide/deep-dive/resources.md +1 -1
- data/docs/modules/definition.md +55 -20
- data/docs/modules/table.md +1 -1
- data/docs/public/plutonium.mdc +27 -14
- data/lib/plutonium/core/controller.rb +2 -2
- data/lib/plutonium/core/controllers/entity_scoping.rb +5 -0
- data/lib/plutonium/engine.rb +3 -2
- data/lib/plutonium/interaction/response/redirect.rb +12 -1
- data/lib/plutonium/resource/register.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +4 -0
- data/lib/plutonium/ui/form/components/key_value_store.rb +234 -0
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +8 -2
- data/lib/plutonium/ui/page/interactive_action.rb +23 -0
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/slim_select.css +37 -0
- data/src/js/controllers/key_value_store_controller.js +109 -0
- data/src/js/controllers/register_controllers.js +2 -0
- data/src/js/controllers/remote_modal_controller.js +24 -17
- data/src/js/controllers/slim_select_controller.js +201 -8
- data/src/js/core.js +2 -1
- data/src/js/plutonium.js +2 -2
- data/yarn.lock +3840 -0
- metadata +6 -2
@@ -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,
|
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
|
data/docs/modules/definition.md
CHANGED
@@ -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,
|
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,
|
162
|
-
input :author, as: :select,
|
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,
|
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
|
-
###
|
856
|
+
### Choices Options (for selects)
|
833
857
|
```ruby
|
834
|
-
|
835
|
-
input :
|
858
|
+
# Static choices
|
859
|
+
input :category, as: :select, choices: %w[Tech Business Lifestyle]
|
836
860
|
|
837
|
-
#
|
838
|
-
|
839
|
-
|
840
|
-
|
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
|
-
#
|
844
|
-
input :
|
845
|
-
|
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
|
850
|
-
|
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
|
-
|
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
|
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.
|
data/docs/modules/table.md
CHANGED
data/docs/public/plutonium.mdc
CHANGED
@@ -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,
|
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,
|
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
|
-
####
|
242
|
+
#### Choices Options (for selects)
|
243
243
|
```ruby
|
244
|
-
|
245
|
-
input :
|
246
|
-
|
247
|
-
#
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
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.
|
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.
|
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
|
data/lib/plutonium/engine.rb
CHANGED
@@ -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.
|
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
|
@@ -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
|