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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-behavior/SKILL.md +22 -0
- data/.claude/skills/plutonium-resource/SKILL.md +76 -2
- data/.claude/skills/plutonium-ui/SKILL.md +17 -3
- data/CHANGELOG.md +45 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +112 -26
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +31 -31
- data/app/assets/plutonium.min.js.map +4 -4
- data/config/initializers/rabl.rb +16 -0
- data/docs/.vitepress/config.ts +1 -0
- data/docs/public/images/reference/structured-inputs-removed.png +0 -0
- data/docs/public/images/reference/structured-inputs.png +0 -0
- data/docs/public/templates/lite.rb +10 -0
- data/docs/reference/generators/lite.md +65 -0
- data/docs/reference/resource/definition.md +128 -2
- data/docs/reference/ui/assets.md +14 -0
- data/docs/reference/ui/displays.md +27 -1
- data/docs/reference/ui/forms.md +2 -1
- data/docs/reference/ui/layouts.md +33 -0
- data/docs/superpowers/plans/2026-06-02-structured-inputs.md +1061 -0
- data/docs/superpowers/plans/2026-06-02-structured-inputs.md.tasks.json +60 -0
- data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md +857 -0
- data/docs/superpowers/plans/2026-06-04-sqlite-tune-maintenance-generators.md.tasks.json +45 -0
- data/docs/superpowers/specs/2026-06-01-structured-inputs-design.md +191 -0
- data/docs/superpowers/specs/2026-06-04-sqlite-tune-maintenance-generators-design.md +238 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +4 -1
- data/lib/generators/pu/lib/plutonium_generators/concerns/configures_recurring.rb +89 -0
- data/lib/generators/pu/lite/maintenance/maintenance_generator.rb +45 -0
- data/lib/generators/pu/lite/maintenance/templates/app/jobs/sqlite_maintenance_job.rb.tt +60 -0
- data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +4 -51
- data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +1 -1
- data/lib/generators/pu/lite/tune/tune_generator.rb +105 -0
- data/lib/plutonium/definition/base.rb +1 -0
- data/lib/plutonium/definition/structured_inputs.rb +67 -0
- data/lib/plutonium/interaction/README.md +24 -78
- data/lib/plutonium/interaction/base.rb +10 -2
- data/lib/plutonium/models/has_cents.rb +10 -0
- data/lib/plutonium/resource/controller.rb +6 -1
- data/lib/plutonium/resource/controllers/interactive_actions.rb +27 -6
- data/lib/plutonium/routing/mapper_extensions.rb +5 -0
- data/lib/plutonium/structured_inputs/param_cleaner.rb +36 -0
- data/lib/plutonium/structured_inputs/params_concern.rb +36 -0
- data/lib/plutonium/ui/display/base.rb +9 -0
- data/lib/plutonium/ui/display/components/badge.rb +83 -0
- data/lib/plutonium/ui/display/components/boolean.rb +28 -6
- data/lib/plutonium/ui/display/components/currency.rb +50 -0
- data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
- data/lib/plutonium/ui/display/theme.rb +5 -0
- data/lib/plutonium/ui/form/base.rb +5 -0
- data/lib/plutonium/ui/form/components/toggle.rb +14 -0
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +17 -28
- data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +67 -0
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +145 -0
- data/lib/plutonium/ui/form/concerns/repeater_field_styles.rb +24 -0
- data/lib/plutonium/ui/form/interaction.rb +7 -2
- data/lib/plutonium/ui/form/options/inferred_types.rb +2 -0
- data/lib/plutonium/ui/form/resource.rb +5 -1
- data/lib/plutonium/ui/form/theme.rb +12 -0
- data/lib/plutonium/ui/grid/card.rb +58 -21
- data/lib/plutonium/ui/layout/icon_rail.rb +29 -9
- data/lib/plutonium/ui/modal/slideover.rb +9 -3
- data/lib/plutonium/ui/sidebar_menu.rb +29 -0
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +5 -4
- data/src/css/components.css +136 -5
- data/src/js/controllers/dirty_form_guard_controller.js +55 -4
- data/src/js/controllers/nested_resource_form_fields_controller.js +35 -12
- data/src/js/controllers/register_controllers.js +2 -0
- data/src/js/controllers/resource_drop_down_controller.js +49 -14
- data/src/js/controllers/structured_input_row_controller.js +26 -0
- metadata +30 -8
- data/docs/superpowers/specs/2026-06-01-interaction-repeater-inputs-design.md +0 -178
- 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
|
-
|
|
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
|
|
89
|
-
|
|
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
|
|
@@ -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
|
|
265
|
+
### Interactions with Structured Input
|
|
266
266
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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::
|
|
29
|
-
|
|
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 ||=
|
|
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
|
|
243
|
-
# @return [Hash] The submitted
|
|
243
|
+
# Returns the submitted interaction parameters
|
|
244
|
+
# @return [Hash] The submitted interaction parameters
|
|
244
245
|
def submitted_interaction_params
|
|
245
|
-
@submitted_interaction_params ||=
|
|
246
|
-
|
|
247
|
-
.
|
|
248
|
-
.
|
|
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
|