plutonium 0.54.0 → 0.56.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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-behavior/SKILL.md +22 -0
  3. data/.claude/skills/plutonium-resource/SKILL.md +76 -2
  4. data/.claude/skills/plutonium-ui/SKILL.md +17 -3
  5. data/CHANGELOG.md +45 -0
  6. data/app/assets/plutonium.css +1 -1
  7. data/app/assets/plutonium.js +112 -26
  8. data/app/assets/plutonium.js.map +4 -4
  9. data/app/assets/plutonium.min.js +31 -31
  10. data/app/assets/plutonium.min.js.map +4 -4
  11. data/config/initializers/rabl.rb +16 -0
  12. data/docs/.vitepress/config.ts +1 -0
  13. data/docs/public/images/reference/structured-inputs-removed.png +0 -0
  14. data/docs/public/images/reference/structured-inputs.png +0 -0
  15. data/docs/public/templates/lite.rb +10 -0
  16. data/docs/reference/generators/lite.md +65 -0
  17. data/docs/reference/resource/definition.md +128 -2
  18. data/docs/reference/ui/assets.md +14 -0
  19. data/docs/reference/ui/displays.md +27 -1
  20. data/docs/reference/ui/forms.md +2 -1
  21. data/docs/reference/ui/layouts.md +33 -0
  22. data/docs/superpowers/plans/2026-06-02-structured-inputs.md +1061 -0
  23. data/docs/superpowers/plans/2026-06-02-structured-inputs.md.tasks.json +60 -0
  24. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md +857 -0
  25. data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json +45 -0
  26. data/docs/superpowers/specs/2026-06-01-structured-inputs-design.md +191 -0
  27. data/docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md +238 -0
  28. data/gemfiles/rails_7.gemfile.lock +1 -1
  29. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  30. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  31. data/lib/generators/pu/core/update/update_generator.rb +4 -1
  32. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb +89 -0
  33. data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +45 -0
  34. data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +60 -0
  35. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +4 -51
  36. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +1 -1
  37. data/lib/generators/pu/lite/tune/tune_generator.rb +105 -0
  38. data/lib/plutonium/definition/base.rb +1 -0
  39. data/lib/plutonium/definition/structured_inputs.rb +67 -0
  40. data/lib/plutonium/interaction/README.md +24 -78
  41. data/lib/plutonium/interaction/base.rb +10 -2
  42. data/lib/plutonium/models/has_cents.rb +10 -0
  43. data/lib/plutonium/resource/controller.rb +6 -1
  44. data/lib/plutonium/resource/controllers/interactive_actions.rb +27 -6
  45. data/lib/plutonium/routing/mapper_extensions.rb +5 -0
  46. data/lib/plutonium/structured_inputs/param_cleaner.rb +36 -0
  47. data/lib/plutonium/structured_inputs/params_concern.rb +36 -0
  48. data/lib/plutonium/ui/display/base.rb +9 -0
  49. data/lib/plutonium/ui/display/components/badge.rb +83 -0
  50. data/lib/plutonium/ui/display/components/boolean.rb +28 -6
  51. data/lib/plutonium/ui/display/components/currency.rb +50 -0
  52. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  53. data/lib/plutonium/ui/display/theme.rb +5 -0
  54. data/lib/plutonium/ui/form/base.rb +5 -0
  55. data/lib/plutonium/ui/form/components/toggle.rb +14 -0
  56. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +17 -28
  57. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
  58. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +145 -0
  59. data/lib/plutonium/ui/form/concerns/repeater_field_styles.rb +24 -0
  60. data/lib/plutonium/ui/form/interaction.rb +7 -2
  61. data/lib/plutonium/ui/form/options/inferred_types.rb +2 -0
  62. data/lib/plutonium/ui/form/resource.rb +5 -1
  63. data/lib/plutonium/ui/form/theme.rb +12 -0
  64. data/lib/plutonium/ui/grid/card.rb +58 -21
  65. data/lib/plutonium/ui/layout/icon_rail.rb +29 -9
  66. data/lib/plutonium/ui/modal/slideover.rb +9 -3
  67. data/lib/plutonium/ui/sidebar_menu.rb +29 -0
  68. data/lib/plutonium/version.rb +1 -1
  69. data/package.json +1 -1
  70. data/plutonium.gemspec +5 -4
  71. data/src/css/components.css +136 -5
  72. data/src/js/controllers/dirty_form_guard_controller.js +55 -4
  73. data/src/js/controllers/nested_resource_form_fields_controller.js +35 -12
  74. data/src/js/controllers/register_controllers.js +2 -0
  75. data/src/js/controllers/resource_drop_down_controller.js +49 -14
  76. data/src/js/controllers/structured_input_row_controller.js +26 -0
  77. metadata +30 -8
  78. data/docs/superpowers/specs/2026-06-01-interaction-repeater-inputs-design.md +0 -178
  79. data/lib/plutonium/interaction/nested_attributes.rb +0 -93
@@ -0,0 +1,60 @@
1
+ class SqliteMaintenanceJob < ApplicationJob
2
+ queue_as :default
3
+
4
+ # Isolated connection for maintenance. Establishing on this dedicated
5
+ # abstract class (instead of ActiveRecord::Base) means we never mutate
6
+ # the global primary connection — a sibling job on the other worker
7
+ # thread keeps talking to the right database.
8
+ class MaintenanceConnection < ActiveRecord::Base
9
+ self.abstract_class = true
10
+ end
11
+
12
+ # Names match the keys in config/database.yml. Add your own database
13
+ # names here if you run extra SQLite databases.
14
+ #
15
+ # PRAGMA optimize is cheap (just refreshes query-planner stats, brief
16
+ # shared lock) so it runs everywhere. Full VACUUM rewrites the file
17
+ # under a global *exclusive* lock for its whole duration, so it only
18
+ # runs on databases without live 24/7 writers.
19
+ OPTIMIZE_DBS = %w[primary queue cache cable errors rails_pulse].freeze
20
+
21
+ # queue/cable/cache are deliberately excluded: SolidQueue, Solid Cable
22
+ # and Solid Cache write to them constantly, and a VACUUM lock there
23
+ # stalls (and errors out) those processes — e.g. SolidQueue's process
24
+ # deregistration hitting "database is locked". They also barely benefit:
25
+ # in WAL mode deleted pages land on the freelist and get reused, so a
26
+ # churning DB sits at a steady-state size without nightly reclamation.
27
+ VACUUM_DBS = %w[primary errors rails_pulse].freeze
28
+
29
+ def perform
30
+ OPTIMIZE_DBS.each { |db_name| run_maintenance(db_name) }
31
+ end
32
+
33
+ private
34
+
35
+ def run_maintenance(db_name)
36
+ config = ActiveRecord::Base.configurations.configs_for(
37
+ env_name: Rails.env,
38
+ name: db_name,
39
+ include_hidden: true
40
+ )
41
+ return unless config
42
+
43
+ MaintenanceConnection.establish_connection(config)
44
+ MaintenanceConnection.connection_pool.with_connection do |conn|
45
+ Rails.logger.info { "[SqliteMaintenance] PRAGMA optimize on #{db_name}" }
46
+ conn.execute("PRAGMA optimize")
47
+
48
+ next unless VACUUM_DBS.include?(db_name)
49
+
50
+ Rails.logger.info { "[SqliteMaintenance] VACUUM on #{db_name}" }
51
+ started = Time.current
52
+ conn.execute("VACUUM")
53
+ Rails.logger.info { "[SqliteMaintenance] VACUUM on #{db_name} done in #{(Time.current - started).round(2)}s" }
54
+ end
55
+ rescue => e
56
+ Rails.error.report(e, context: {db: db_name, action: "sqlite_maintenance"})
57
+ ensure
58
+ MaintenanceConnection.remove_connection
59
+ end
60
+ end
@@ -7,6 +7,7 @@ module Pu
7
7
  class RailsPulseGenerator < Rails::Generators::Base
8
8
  include PlutoniumGenerators::Generator
9
9
  include PlutoniumGenerators::Concerns::ConfiguresSqlite
10
+ include PlutoniumGenerators::Concerns::ConfiguresRecurring
10
11
  include PlutoniumGenerators::Concerns::MountsEngines
11
12
 
12
13
  source_root File.expand_path("templates", __dir__)
@@ -69,25 +70,11 @@ module Pu
69
70
  end
70
71
 
71
72
  def setup_recurring_tasks
72
- recurring_file = "config/recurring.yml"
73
- full_path = File.expand_path(recurring_file, destination_root)
74
- return unless File.exist?(full_path)
75
- return if file_includes?(recurring_file, "rails_pulse")
76
-
77
- content = File.read(full_path)
78
- env_keys = %w[production development staging test]
79
- env_scoped = content.lines.any? { |l| l.match?(/^(#{env_keys.join("|")}):\s*$/) }
80
-
81
- if env_scoped
82
- create_file recurring_file, inject_rails_pulse_under_envs(content, env_keys), force: true
83
- else
84
- append_to_file recurring_file, "\n" + rails_pulse_tasks_yaml(0)
85
- end
73
+ add_recurring_tasks(rails_pulse_tasks_yaml, marker: "rails_pulse")
86
74
  end
87
75
 
88
- def rails_pulse_tasks_yaml(indent)
89
- pad = " " * indent
90
- <<~YAML.gsub(/^(?=.)/, pad)
76
+ def rails_pulse_tasks_yaml
77
+ <<~YAML
91
78
  rails_pulse_summary:
92
79
  class: RailsPulse::SummaryJob
93
80
  queue: default
@@ -101,40 +88,6 @@ module Pu
101
88
  description: "Archive/purge old Rails Pulse data"
102
89
  YAML
103
90
  end
104
-
105
- def inject_rails_pulse_under_envs(content, env_keys)
106
- lines = content.lines
107
- env_re = /^(#{env_keys.join("|")}):\s*$/
108
-
109
- env_starts = lines.each_with_index.select { |l, _| env_re.match?(l) }.map(&:last)
110
-
111
- env_starts.reverse_each do |start|
112
- end_idx = lines.length
113
- ((start + 1)...lines.length).each do |i|
114
- if lines[i].match?(/^[^\s#]/)
115
- end_idx = i
116
- break
117
- end
118
- end
119
-
120
- indent = 2
121
- ((start + 1)...end_idx).each do |i|
122
- if (m = lines[i].match(/^(\s+)\S/))
123
- indent = m[1].length
124
- break
125
- end
126
- end
127
-
128
- insert_at = end_idx
129
- while insert_at > start + 1 && lines[insert_at - 1].strip.empty?
130
- insert_at -= 1
131
- end
132
-
133
- lines.insert(insert_at, "\n", rails_pulse_tasks_yaml(indent))
134
- end
135
-
136
- lines.join
137
- end
138
91
  end
139
92
  end
140
93
  end
@@ -27,6 +27,6 @@ RailsPulse.configure do |config|
27
27
  <%- if options[:database] -%>
28
28
 
29
29
  # Use separate database for performance data
30
- config.connects_to = {database: {writing: :<%= options[:database] %>}}
30
+ config.connects_to = {database: {writing: :<%= options[:database] %>, reading: :<%= options[:database] %>}}
31
31
  <%- end -%>
32
32
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ module Lite
7
+ class TuneGenerator < Rails::Generators::Base
8
+ include PlutoniumGenerators::Generator
9
+
10
+ desc "Tune config/database.yml with performance pragmas for SQLite"
11
+
12
+ RAILS_8_1 = ::Gem::Version.new("8.1.0")
13
+ DATABASE_YML = "config/database.yml"
14
+
15
+ def start
16
+ path = File.expand_path(DATABASE_YML, destination_root)
17
+ unless File.exist?(path)
18
+ log :skip, "#{DATABASE_YML} not found"
19
+ return
20
+ end
21
+
22
+ content = File.read(path)
23
+ if content.include?("wal_autocheckpoint")
24
+ log :skip, "pragmas already tuned in #{DATABASE_YML}"
25
+ return
26
+ end
27
+
28
+ new_content = apply_pragmas(content, rails_version)
29
+ if new_content == content
30
+ log :skip, "no `default: &default` block in #{DATABASE_YML}"
31
+ return
32
+ end
33
+
34
+ create_file DATABASE_YML, new_content, force: true
35
+ say_status :tune, "added SQLite pragmas to #{DATABASE_YML}"
36
+ rescue => e
37
+ exception "#{self.class} failed:", e
38
+ end
39
+
40
+ private
41
+
42
+ # Pure: returns content with pragmas inserted into the `default: &default`
43
+ # block. Merges into an existing default-level `pragmas:` mapping (2-space
44
+ # indent) if present, otherwise inserts a fresh pragmas block. Returns the
45
+ # content unchanged when there is no default anchor. Scoped to the default
46
+ # block so a `pragmas:` nested under another env (e.g. production.primary)
47
+ # is never touched.
48
+ def apply_pragmas(content, version)
49
+ anchor = content.match(/^default: &default\n/)
50
+ return content unless anchor
51
+
52
+ body_start = anchor.end(0)
53
+ rest = content[body_start..]
54
+ # the default block runs until the next top-level (column-0) line
55
+ next_top = rest =~ /^\S/
56
+ default_body = next_top ? rest[0...next_top] : rest
57
+ tail = next_top ? rest[next_top..] : ""
58
+
59
+ if default_body.match?(/^ pragmas:\s*$/)
60
+ new_body = default_body.sub(/^( pragmas:[ \t]*\n)/) { $1 + pragma_keys(version) }
61
+ content[0...body_start] + new_body + tail
62
+ else
63
+ content.sub(/^default: &default\n/, "default: &default\n" + pragma_block(version))
64
+ end
65
+ end
66
+
67
+ def pragma_block(version)
68
+ comment = <<~COMMENT.gsub(/^/, " ")
69
+ # Plutonium-tuned SQLite pragmas (pu:lite:tune).
70
+ # Rails 8.1+ already sets WAL, synchronous=NORMAL, foreign_keys,
71
+ # mmap=128MB and journal_size_limit by default; only deltas are added
72
+ # there. We intentionally do NOT set SQLite's internal busy pragma —
73
+ # Rails routes the `timeout:` key to the sqlite3 gem's constant-poll
74
+ # busy_handler_timeout, which has better tail-latency than SQLite's
75
+ # backoff.
76
+ pragmas:
77
+ COMMENT
78
+ comment + pragma_keys(version)
79
+ end
80
+
81
+ def pragma_keys(version)
82
+ keys = +""
83
+ if version < RAILS_8_1
84
+ keys << <<~YAML.gsub(/^/, " ")
85
+ journal_mode: WAL
86
+ synchronous: NORMAL
87
+ foreign_keys: true
88
+ journal_size_limit: 67108864 # 64 MB
89
+ YAML
90
+ end
91
+ keys << <<~YAML.gsub(/^/, " ")
92
+ cache_size: -64000 # 64 MB page cache (default ~2 MB is too small)
93
+ temp_store: 2 # MEMORY — sorts/temp indexes stay off disk
94
+ mmap_size: 536870912 # 512 MB (override the 128 MB default)
95
+ wal_autocheckpoint: 10000 # checkpoint every ~40 MB of WAL, fewer pauses
96
+ YAML
97
+ keys
98
+ end
99
+
100
+ def rails_version
101
+ @rails_version ||= ::Gem::Version.new(Rails::VERSION::STRING).release
102
+ end
103
+ end
104
+ end
105
+ end
@@ -33,6 +33,7 @@ module Plutonium
33
33
  include Scoping
34
34
  include Search
35
35
  include NestedInputs
36
+ include StructuredInputs
36
37
  include IndexViews
37
38
  include Metadata
38
39
 
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Definition
5
+ # Classless structured inputs: a group of fields collected into a hash
6
+ # (single) or an array of hashes (when `repeat:` is given). Mixed into both
7
+ # resource definitions and interactions.
8
+ #
9
+ # @example
10
+ # structured_input :address do |f|
11
+ # f.input :street
12
+ # f.input :city
13
+ # end
14
+ #
15
+ # structured_input :contacts, repeat: 10 do |f|
16
+ # f.input :label
17
+ # f.input :phone_number
18
+ # end
19
+ module StructuredInputs
20
+ extend ActiveSupport::Concern
21
+
22
+ # Holder built per render from a structured_input block. Reuses the same
23
+ # field/input DSL as the rest of Plutonium definitions.
24
+ class FieldsDefinition
25
+ include Plutonium::Definition::DefineableProps
26
+
27
+ defineable_props :field, :input
28
+ end
29
+
30
+ class_methods do
31
+ # @option options [Integer, true] :repeat presence => array; Integer => max rows
32
+ # @option options [Class] :using a FieldsDefinition-like class instead of a block
33
+ # @option options [Array<Symbol>] :fields restrict rendered fields
34
+ def structured_input(name, **options, &block)
35
+ unless block || options[:using]
36
+ raise ArgumentError,
37
+ "`structured_input :#{name}` needs a block or `using:` — e.g. " \
38
+ "`structured_input :#{name} do |f| f.input :field end` or " \
39
+ "`structured_input :#{name}, using: #{name.to_s.classify}Fields`"
40
+ end
41
+
42
+ defined_structured_inputs[name] = {options:, block:}.compact
43
+ end
44
+
45
+ def defined_structured_inputs
46
+ @defined_structured_inputs ||= {}
47
+ end
48
+
49
+ def inherited(subclass)
50
+ super
51
+ subclass.instance_variable_set(
52
+ :@defined_structured_inputs,
53
+ defined_structured_inputs.deep_dup
54
+ )
55
+ end
56
+ end
57
+
58
+ # Instance access mirrors the defineable_prop convention (where
59
+ # `defined_<plural>` is available on instances). The form's render path and
60
+ # the param cleaner both hold a definition instance, so they read the
61
+ # registry through here.
62
+ def defined_structured_inputs
63
+ self.class.defined_structured_inputs
64
+ end
65
+ end
66
+ end
67
+ end
@@ -262,71 +262,21 @@ class MyInteraction < Plutonium::Interaction::Base
262
262
  end
263
263
  ```
264
264
 
265
- ### Interactions with Nested Attributes
265
+ ### Interactions with Structured Input
266
266
 
267
- This example demonstrates how to handle nested attributes—specifically,
268
- a `User` with multiple `Contact` and `UserAddress` records using
269
- a Plutonium `Interaction`.
267
+ > **Note:** `nested_input` and `accepts_nested_attributes_for` are **not**
268
+ > available on interactions. They are resource-only features that work with
269
+ > model-backed `has_many`/`has_one` associations. For collecting structured or
270
+ > repeating input inside an interaction, use `structured_input` instead.
270
271
 
271
- #### Key Highlights
272
+ `structured_input` declares an attribute on the interaction and renders an
273
+ inline fieldset in the auto-generated form. It comes in two forms:
272
274
 
273
- The model definitions are included here for completeness, but the primary focus
274
- remains on demonstrating how to build interactions that handle nested
275
- attributes.
276
-
277
- - Core user attributes (`first_name`, `last_name`, `email`) are declared and
278
- validated at the top level of the interaction.
279
-
280
- - Nested associations (`contacts`, `addresses`) are managed via
281
- `accepts_nested_attributes_for`. The optional `reject_if` condition is used
282
- to discard entries that lack required fields—helping ensure data integrity at
283
- the input level.
284
-
285
- - The `nested_input` DSL provides a declarative way to structure nested inputs,
286
- specifying accepted fields and mapping them to their respective definition
287
- classes (`ContactDefinition` and `UserAddressDefinition`).
288
-
289
- - During execution, a `User` instance is initialized with both top-level and
290
- nested attributes, then persisted with all applicable validations.
291
-
292
- **Note:** The `class_name` option is explicitly defined in the interaction's
293
- `accepts_nested_attributes_for` macro because the `addresses` association does
294
- not directly map to its underlying model name. Simply provide the class name,
295
- for example, `class_name: "UserAddress"`, to ensure the correct model is used.
296
-
297
- **This is essential only when the association name differs from the actual
298
- class name.**
299
-
300
- This approach enables seamless handling of complex nested input from forms or
301
- API requests, while keeping validation logic clean, maintainable, and modular.
275
+ - **Single** the attribute arrives in `execute` as a plain `Hash`.
276
+ - **Repeat** the attribute arrives as an `Array` of hashes (capped at the
277
+ given number of rows; `repeat: true` defaults to 10).
302
278
 
303
279
  ```ruby
304
- # app/models/user.rb
305
- class User < ApplicationRecord
306
- include Plutonium::Resource::Record
307
-
308
- has_many :contacts
309
- has_many :addresses, class_name: "UserAddress"
310
-
311
- accepts_nested_attributes_for :contacts, :addresses
312
- end
313
-
314
- # app/models/contact.rb
315
- class Contact < ApplicationRecord
316
- include Plutonium::Resource::Record
317
-
318
- belongs_to :user
319
- validates :label, :phone_number, presence: true
320
- end
321
-
322
- # app/models/user_address.rb
323
- class UserAddress < ApplicationRecord
324
- include Plutonium::Resource::Record
325
-
326
- belongs_to :user
327
- validates :label, :map_url, presence: true
328
- end
329
-
330
280
  # app/interactions/users/interactions/create_user_interaction.rb
331
281
  module Users
332
282
  module Interactions
@@ -338,37 +288,33 @@ module Users
338
288
  attribute :first_name, :string
339
289
  attribute :last_name, :string
340
290
  attribute :email, :string
341
- attribute :contacts
342
- attribute :addresses
343
291
 
344
292
  validates :first_name, :last_name, presence: true
345
293
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
346
294
 
347
- accepts_nested_attributes_for :contacts,
348
- reject_if: proc { |attributes| attributes[:label].blank? }
349
-
350
- accepts_nested_attributes_for :addresses, class_name: "UserAddress",
351
- reject_if: proc { |attributes| attributes[:label].blank? }
352
-
353
- nested_input :contacts,
354
- using: ContactDefinition,
355
- fields: %i[label phone_number],
356
- description: "Add one or more contacts for this user."
295
+ # single → a hash
296
+ structured_input :address do |f|
297
+ f.input :street
298
+ f.input :city
299
+ end
357
300
 
358
- nested_input :addresses,
359
- using: UserAddressDefinition,
360
- fields: %i[label map_url],
361
- description: "Add one or more addresses for this user."
301
+ # repeater → an array of hashes (max 5 rows)
302
+ structured_input :contacts, repeat: 5 do |f|
303
+ f.input :label
304
+ f.input :phone_number
305
+ end
362
306
 
363
307
  private
364
308
 
365
309
  def execute
366
- user = User.new(self.attributes)
310
+ # address => { "street" => "...", "city" => "..." }
311
+ # contacts => [ { "label" => "...", "phone_number" => "..." }, ... ]
312
+ user = User.new(first_name: first_name, last_name: last_name, email: email)
367
313
 
368
314
  if user.save
369
315
  success(user).with_message("User created successfully")
370
316
  else
371
- failed(user.errors)
317
+ failure(user.errors)
372
318
  end
373
319
  end
374
320
  end
@@ -25,8 +25,16 @@ module Plutonium
25
25
  include Plutonium::Definition::DefineableProps
26
26
  include Plutonium::Definition::ConfigAttr
27
27
  include Plutonium::Definition::Presentable
28
- include Plutonium::Definition::NestedInputs
29
- include Plutonium::Interaction::NestedAttributes
28
+ include Plutonium::Definition::StructuredInputs
29
+
30
+ # On interactions, declaring a structured input also declares the backing
31
+ # ActiveModel attribute so the value survives `attributes=` and appears in
32
+ # `attribute_names` (which drives the interaction form's field list).
33
+ def self.structured_input(name, **options, &block)
34
+ super
35
+ default = options[:repeat] ? -> { [] } : -> { {} }
36
+ attribute name, default: default
37
+ end
30
38
 
31
39
  # include Plutonium::Interaction::Concerns::WorkflowDSL
32
40
 
@@ -166,6 +166,16 @@ module Plutonium
166
166
  def has_cents_attribute?(attribute)
167
167
  has_cents_attributes.key?(attribute.to_sym)
168
168
  end
169
+
170
+ # Checks if a given attribute is the decimal accessor of a has_cents pair
171
+ # (e.g. :amount for `has_cents :amount_cents`).
172
+ #
173
+ # @param attribute [Symbol] The attribute to check
174
+ # @return [Boolean]
175
+ def has_cents_decimal_attribute?(attribute)
176
+ attribute = attribute.to_sym
177
+ has_cents_attributes.any? { |_, opts| opts[:name] == attribute }
178
+ end
169
179
  end
170
180
  end
171
181
  end
@@ -16,6 +16,7 @@ module Plutonium
16
16
  include Plutonium::Resource::Controllers::CrudActions
17
17
  include Plutonium::Resource::Controllers::InteractiveActions
18
18
  include Plutonium::Resource::Controllers::Typeahead
19
+ include Plutonium::StructuredInputs::ParamsConcern
19
20
 
20
21
  included do
21
22
  after_action { response.headers.merge!(@pagy.headers_hash) if @pagy }
@@ -144,7 +145,11 @@ module Plutonium
144
145
  # Use existing record (cloned) for context during param extraction, or new instance for create
145
146
  # Pass form_action: false to prevent form from trying to generate URL (cloned record has id: nil)
146
147
  extraction_record = resource_record?&.dup || resource_class.new
147
- @submitted_resource_params ||= build_form(extraction_record, form_action: false).extract_input(params, view_context:)[resource_param_key.to_sym].compact
148
+ @submitted_resource_params ||= begin
149
+ extracted = build_form(extraction_record, form_action: false)
150
+ .extract_input(params, view_context:)[resource_param_key.to_sym].compact
151
+ clean_structured_inputs(current_definition, extracted)
152
+ end
148
153
  end
149
154
 
150
155
  # Returns the resource parameters, including scoped and parent parameters
@@ -3,6 +3,7 @@ module Plutonium
3
3
  module Controllers
4
4
  module InteractiveActions
5
5
  extend ActiveSupport::Concern
6
+ include Plutonium::StructuredInputs::ParamsConcern
6
7
 
7
8
  included do
8
9
  helper_method :current_interactive_action
@@ -239,13 +240,33 @@ module Plutonium
239
240
  @interaction
240
241
  end
241
242
 
242
- # Returns the submitted resource parameters
243
- # @return [Hash] The submitted resource parameters
243
+ # Returns the submitted interaction parameters
244
+ # @return [Hash] The submitted interaction parameters
244
245
  def submitted_interaction_params
245
- @submitted_interaction_params ||= current_interactive_action
246
- .interaction
247
- .build_form(current_interactive_action.interaction.new(view_context:))
248
- .extract_input(params, view_context:)[:interaction]
246
+ @submitted_interaction_params ||= begin
247
+ action = current_interactive_action
248
+ interaction = action.interaction
249
+ instance = interaction.new(view_context:)
250
+ # Bind the action's subject before the form is rendered for param
251
+ # extraction. extract_input renders the form when it hasn't been
252
+ # rendered yet, which eagerly materializes input choices — any
253
+ # `choices:` proc (or other render-time config) that reads the
254
+ # subject would otherwise run against a nil resource/resources and
255
+ # raise a deep-stack NoMethodError before the interaction ever runs.
256
+ # This mirrors the subject the real instance is given in
257
+ # build_interactive_*_action_interaction. interaction_params still
258
+ # strips :resource/:resources from the extracted params, so
259
+ # mass-assignment safety is unaffected.
260
+ if action.record_action? || action.collection_record_action?
261
+ instance.resource = resource_record!
262
+ elsif action.bulk_action?
263
+ instance.resources = interactive_bulk
264
+ end
265
+ extracted = interaction
266
+ .build_form(instance)
267
+ .extract_input(params, view_context:)[:interaction]
268
+ clean_structured_inputs(interaction, extracted)
269
+ end
249
270
  end
250
271
 
251
272
  def redirect_url_after_action_on(resource_record_or_resource_class)
@@ -99,8 +99,13 @@ module Plutonium
99
99
  next unless base_config
100
100
 
101
101
  # Register with association-based key: "parent_plural/association_name"
102
+ # Force route_type: :resources — has_many associations always nest as a
103
+ # plural (member-with-id) route, even when the child resource is registered
104
+ # `singular: true` at the top level (which would otherwise leak :resource
105
+ # into base_config and make member URL helpers resolve to the wrong name).
102
106
  nested_key = "#{resource.model_name.plural}/#{assoc_info[:name]}"
103
107
  nested_config = base_config.merge(
108
+ route_type: :resources,
104
109
  association_name: assoc_info[:name],
105
110
  resource_class: assoc_info[:klass]
106
111
  )
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module StructuredInputs
5
+ # Normalises an extracted structured-input value before it is stored.
6
+ #
7
+ # The form's `extract_input` already yields a `Hash` (single) or an
8
+ # `Array<Hash>` (repeater), so the only work left is to symbolise keys and,
9
+ # for repeaters, drop rows the user left entirely blank.
10
+ module ParamCleaner
11
+ module_function
12
+
13
+ # @param value [Hash, Array, nil] the extracted param for this input
14
+ # @param repeat [Boolean, Integer] truthy => array (repeater), else hash
15
+ # @return [Hash, Array<Hash>]
16
+ def call(value, repeat:)
17
+ repeat ? clean_collection(value) : clean_one(value)
18
+ end
19
+
20
+ def clean_one(value)
21
+ value.is_a?(Hash) ? symbolize(value) : {}
22
+ end
23
+
24
+ def clean_collection(value)
25
+ Array(value)
26
+ .select { |row| row.is_a?(Hash) }
27
+ .map { |row| symbolize(row) }
28
+ .reject { |row| row.values.all? { |v| v.to_s.strip.empty? } }
29
+ end
30
+
31
+ def symbolize(row)
32
+ row.to_h.transform_keys(&:to_sym)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module StructuredInputs
5
+ # Rewrites structured-input params in place through ParamCleaner. Shared by
6
+ # the resource controller and the interactive-actions controller.
7
+ module ParamsConcern
8
+ # @param definition a definition instance (resource) or class (interaction)
9
+ # exposing `defined_structured_inputs`
10
+ # @param params [Hash] extracted form params (mutable copy)
11
+ # @return [Hash]
12
+ def clean_structured_inputs(definition, params)
13
+ registry = structured_inputs_registry(definition)
14
+ return params unless registry
15
+
16
+ registry.each do |name, entry|
17
+ next unless params.key?(name)
18
+
19
+ repeat = entry[:options]&.fetch(:repeat, false)
20
+ params[name] = Plutonium::StructuredInputs::ParamCleaner.call(params[name], repeat:)
21
+ end
22
+ params
23
+ end
24
+
25
+ private
26
+
27
+ def structured_inputs_registry(definition)
28
+ if definition.respond_to?(:defined_structured_inputs)
29
+ definition.defined_structured_inputs
30
+ elsif definition.class.respond_to?(:defined_structured_inputs)
31
+ definition.class.defined_structured_inputs
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -34,6 +34,15 @@ module Plutonium
34
34
  create_component(Plutonium::UI::Display::Components::Color, :color, **, &)
35
35
  end
36
36
 
37
+ def badge_tag(**, &)
38
+ create_component(Plutonium::UI::Display::Components::Badge, :badge, **, &)
39
+ end
40
+ alias_method :enum_tag, :badge_tag
41
+
42
+ def currency_tag(**, &)
43
+ create_component(Plutonium::UI::Display::Components::Currency, :currency, **, &)
44
+ end
45
+
37
46
  # Type aliases for common column types
38
47
  alias_method :float_tag, :number_tag
39
48
  alias_method :decimal_tag, :number_tag