plutonium 0.25.0 → 0.25.2

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
+ }
@@ -1,7 +1,7 @@
1
1
  module Plutonium
2
2
  module Helpers
3
3
  module TurboStreamActionsHelper
4
- def redirect(url)
4
+ def turbo_stream_redirect(url)
5
5
  turbo_stream_action_tag :redirect, url:
6
6
  end
7
7
  end
@@ -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
@@ -59,7 +59,7 @@ module Plutonium
59
59
  if helpers.current_turbo_frame == "remote_modal"
60
60
  format.turbo_stream do
61
61
  render turbo_stream: [
62
- turbo_stream.redirect(return_url)
62
+ helpers.turbo_stream_redirect(return_url)
63
63
  ]
64
64
  end
65
65
  end
@@ -73,13 +73,6 @@ module Plutonium
73
73
  render "errors", status: :unprocessable_entity
74
74
  end
75
75
 
76
- if helpers.current_turbo_frame == "remote_modal"
77
- format.turbo_stream do
78
- render turbo_stream: [
79
- turbo_stream.replace(:remote_modal, partial: "interactive_action_form")
80
- ]
81
- end
82
- end
83
76
  end
84
77
  end
85
78
  end
@@ -123,7 +116,7 @@ module Plutonium
123
116
  if helpers.current_turbo_frame == "remote_modal"
124
117
  format.turbo_stream do
125
118
  render turbo_stream: [
126
- turbo_stream.redirect(return_url)
119
+ helpers.turbo_stream_redirect(return_url)
127
120
  ]
128
121
  end
129
122
  end
@@ -137,13 +130,6 @@ module Plutonium
137
130
  render "errors", status: :unprocessable_entity
138
131
  end
139
132
 
140
- if helpers.current_turbo_frame == "remote_modal"
141
- format.turbo_stream do
142
- render turbo_stream: [
143
- turbo_stream.replace(:remote_modal, partial: "interactive_action_form")
144
- ]
145
- end
146
- end
147
133
  end
148
134
  end
149
135
  end
@@ -179,7 +165,7 @@ module Plutonium
179
165
  # if helpers.current_turbo_frame == "remote_modal"
180
166
  # format.turbo_stream do
181
167
  # render turbo_stream: [
182
- # turbo_stream.redirect(resource_url_for(resource_class))
168
+ # helpers.turbo_stream_redirect(resource_url_for(resource_class))
183
169
  # ]
184
170
  # end
185
171
  # end
@@ -191,12 +177,6 @@ module Plutonium
191
177
  # @errors = @interaction.errors
192
178
  # render "errors", status: :unprocessable_entity
193
179
  # end
194
-
195
- # if helpers.current_turbo_frame == "remote_modal"
196
- # format.turbo_stream do
197
- # render turbo_stream: turbo_stream.replace(:remote_modal, partial: "interactive_bulk_action_form")
198
- # end
199
- # end
200
180
  # end
201
181
  # end
202
182
  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
@@ -20,14 +20,16 @@ module Plutonium
20
20
  if helpers.current_turbo_frame == "remote_modal"
21
21
  dialog(
22
22
  closedby: "any",
23
- class:
24
- "rounded-md w-full max-w-3xl
25
- backdrop:bg-black/60 backdrop:backdrop-blur-sm
26
- top-auto md:top-1/2 md:-translate-y-1/2 left-1/2 -translate-x-1/2
27
- max-h-[80%] p-6
28
- hidden open:flex flex-col
29
- relative opacity-0 open:opacity-100
30
- transition-opacity duration-300 ease-in-out",
23
+ class: "rounded-md w-full max-w-3xl
24
+ bg-white dark:bg-gray-800
25
+ border border-gray-200 dark:border-gray-700
26
+ shadow-lg dark:shadow-gray-900/20
27
+ backdrop:bg-black/60 backdrop:backdrop-blur-sm
28
+ top-auto md:top-1/2 md:-translate-y-1/2 left-1/2 -translate-x-1/2
29
+ max-h-[80%] p-6
30
+ hidden open:flex flex-col
31
+ relative opacity-0 open:opacity-100
32
+ transition-opacity duration-300 ease-in-out",
31
33
  data: {controller: "remote-modal"}
32
34
  ) do
33
35
  render_page_header
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.25.0"
2
+ VERSION = "0.25.2"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Core assets for the Plutonium gem",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
@@ -0,0 +1,109 @@
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.updateAddButtonState()
29
+
30
+ // Focus on the key input of the new pair
31
+ const newKeyInput = this.containerTarget.lastElementChild.querySelector('[data-key-value-store-target="keyInput"]')
32
+ if (newKeyInput) {
33
+ newKeyInput.focus()
34
+ }
35
+ }
36
+
37
+ removePair(event) {
38
+ event.preventDefault()
39
+
40
+ const pair = event.target.closest('[data-key-value-store-target="pair"]')
41
+ if (pair) {
42
+ pair.remove()
43
+ this.updateIndices()
44
+ this.updateAddButtonState()
45
+ }
46
+ }
47
+
48
+ updateIndices() {
49
+ this.pairTargets.forEach((pair, index) => {
50
+ const keyInput = pair.querySelector('[data-key-value-store-target="keyInput"]')
51
+ const valueInput = pair.querySelector('[data-key-value-store-target="valueInput"]')
52
+
53
+ if (keyInput) {
54
+ keyInput.name = keyInput.name.replace(/\[\d+\]/, `[${index}]`)
55
+ }
56
+ if (valueInput) {
57
+ valueInput.name = valueInput.name.replace(/\[\d+\]/, `[${index}]`)
58
+ }
59
+ })
60
+ }
61
+
62
+ updatePairIndices(element, index) {
63
+ const inputs = element.querySelectorAll('input')
64
+ inputs.forEach(input => {
65
+ if (input.name) {
66
+ input.name = input.name.replace('__INDEX__', index)
67
+ }
68
+ })
69
+ }
70
+
71
+ updateAddButtonState() {
72
+ const addButton = this.addButtonTarget
73
+ if (this.pairTargets.length >= this.limitValue) {
74
+ addButton.disabled = true
75
+ addButton.classList.add('opacity-50', 'cursor-not-allowed')
76
+ } else {
77
+ addButton.disabled = false
78
+ addButton.classList.remove('opacity-50', 'cursor-not-allowed')
79
+ }
80
+ }
81
+
82
+ // Serialize the current key-value pairs to JSON
83
+ toJSON() {
84
+ const pairs = {}
85
+ this.pairTargets.forEach(pair => {
86
+ const keyInput = pair.querySelector('[data-key-value-store-target="keyInput"]')
87
+ const valueInput = pair.querySelector('[data-key-value-store-target="valueInput"]')
88
+
89
+ if (keyInput && valueInput && keyInput.value.trim()) {
90
+ pairs[keyInput.value.trim()] = valueInput.value
91
+ }
92
+ })
93
+ return JSON.stringify(pairs)
94
+ }
95
+
96
+ // Get the current key-value pairs as an object
97
+ toObject() {
98
+ const pairs = {}
99
+ this.pairTargets.forEach(pair => {
100
+ const keyInput = pair.querySelector('[data-key-value-store-target="keyInput"]')
101
+ const valueInput = pair.querySelector('[data-key-value-store-target="valueInput"]')
102
+
103
+ if (keyInput && valueInput && keyInput.value.trim()) {
104
+ pairs[keyInput.value.trim()] = valueInput.value
105
+ }
106
+ })
107
+ return pairs
108
+ }
109
+ }
@@ -19,6 +19,7 @@ import AttachmentPreviewContainerController from "./attachment_preview_container
19
19
  import SidebarController from "./sidebar_controller.js"
20
20
  import PasswordVisibilityController from "./password_visibility_controller.js"
21
21
  import RemoteModalController from "./remote_modal_controller.js"
22
+ import KeyValueStoreController from "./key_value_st\ore_controller.js"
22
23
 
23
24
  export default function (application) {
24
25
  // Register controllers here
@@ -42,4 +43,5 @@ export default function (application) {
42
43
  application.register("attachment-preview", AttachmentPreviewController)
43
44
  application.register("attachment-preview-container", AttachmentPreviewContainerController)
44
45
  application.register("remote-modal", RemoteModalController)
46
+ application.register("key-value-store", KeyValueStoreController)
45
47
  }
data/src/js/plutonium.js CHANGED
@@ -1,3 +1,5 @@
1
+ import "@hotwired/turbo"
2
+
1
3
  import { Application } from "@hotwired/stimulus"
2
4
  const application = Application.start()
3
5
 
@@ -1,5 +1,3 @@
1
- import * as Turbo from "@hotwired/turbo"
2
-
3
- import "./turbo_debug"
4
- // import "./turbo_actions"
1
+ import "./turbo_actions"
2
+ // import "./turbo_debug"
5
3
  // import "./turbo_frame_monkey_patch"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plutonium
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.25.0
4
+ version: 0.25.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-07-01 00:00:00.000000000 Z
11
+ date: 2025-07-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: zeitwerk
@@ -418,6 +418,7 @@ files:
418
418
  - app/assets/plutonium.min.js
419
419
  - app/assets/plutonium.min.js.map
420
420
  - app/assets/plutonium.png
421
+ - app/javascript/controllers/key_value_store_controller.js
421
422
  - app/views/layouts/resource.html.erb
422
423
  - app/views/layouts/rodauth.html.erb
423
424
  - app/views/plutonium/_flash.html.erb
@@ -839,6 +840,7 @@ files:
839
840
  - lib/plutonium/ui/form/components/easymde.rb
840
841
  - lib/plutonium/ui/form/components/flatpickr.rb
841
842
  - lib/plutonium/ui/form/components/intl_tel_input.rb
843
+ - lib/plutonium/ui/form/components/key_value_store.rb
842
844
  - lib/plutonium/ui/form/components/secure_association.rb
843
845
  - lib/plutonium/ui/form/components/secure_polymorphic_association.rb
844
846
  - lib/plutonium/ui/form/components/uppy.rb
@@ -908,6 +910,7 @@ files:
908
910
  - src/js/controllers/form_controller.js
909
911
  - src/js/controllers/frame_navigator_controller.js
910
912
  - src/js/controllers/intl_tel_input_controller.js
913
+ - src/js/controllers/key_value_store_controller.js
911
914
  - src/js/controllers/nested_resource_form_fields_controller.js
912
915
  - src/js/controllers/password_visibility_controller.js
913
916
  - src/js/controllers/register_controllers.js