plutonium 0.21.0 → 0.21.1

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.
@@ -262,6 +262,120 @@ class MyInteraction < Plutonium::Interaction::Base
262
262
  end
263
263
  ```
264
264
 
265
+ ### Interactions with Nested Attributes
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`.
270
+
271
+ #### Key Highlights
272
+
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.
302
+
303
+ ```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
+ # app/interactions/users/interactions/create_user_interaction.rb
331
+ module Users
332
+ module Interactions
333
+ class CreateUserInteraction < Plutonium::Interaction::Base
334
+ include Plutonium::Definition::Presentable
335
+
336
+ presents label: "Add a new user", icon: Phlex::Tabler::UserPlus
337
+
338
+ attribute :first_name, :string
339
+ attribute :last_name, :string
340
+ attribute :email, :string
341
+ attribute :contacts
342
+ attribute :addresses
343
+
344
+ validates :first_name, :last_name, presence: true
345
+ validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
346
+
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."
357
+
358
+ nested_input :addresses,
359
+ using: UserAddressDefinition,
360
+ fields: %i[label map_url],
361
+ description: "Add one or more addresses for this user."
362
+
363
+ private
364
+
365
+ def execute
366
+ user = User.new(self.attributes)
367
+
368
+ if user.save
369
+ success(user).with_message("User created successfully")
370
+ else
371
+ failed(user.errors)
372
+ end
373
+ end
374
+ end
375
+ end
376
+ end
377
+ ```
378
+
265
379
  ## Examples
266
380
 
267
381
  ### Chaining Operations
@@ -311,7 +425,6 @@ This example demonstrates how to chain multiple operations, handle potential fai
311
425
 
312
426
  By following these guidelines and examples, you can effectively implement and use the Use Case Driven Design pattern in your Rails applications, leading to more maintainable and testable code.
313
427
 
314
-
315
428
  ### Example interaction with workflow
316
429
 
317
430
  ```ruby
@@ -367,7 +480,6 @@ module Orders
367
480
  end
368
481
  ```
369
482
 
370
-
371
483
  class Sample < Phlex::HTML
372
484
  def view_template
373
485
  p { "my custom template" }
@@ -25,6 +25,8 @@ 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
30
  # include Plutonium::Interaction::Concerns::WorkflowDSL
29
31
 
30
32
  class Form < Plutonium::UI::Form::Interaction; end
@@ -89,6 +91,7 @@ module Plutonium
89
91
  def succeed(value = nil)
90
92
  Plutonium::Interaction::Outcome::Success.new(value)
91
93
  end
94
+
92
95
  alias_method :success, :succeed
93
96
 
94
97
  def failed(errors = nil, attribute = :base)
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Interaction
5
+ module NestedAttributes
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ # Dynamically defines writer and reader methods for handling nested
10
+ # attributes in form objects or interaction classes, mimicking the
11
+ # behavior of ActiveRecord's `accepts_nested_attributes_for`.
12
+ #
13
+ # This method allows you to pass in nested data (e.g. from a form) and
14
+ # automatically build or destroy associated records based on that input.
15
+ #
16
+ # === Example 1: Basic usage with default naming
17
+ # # If `Contact` is the associated model inferred from the
18
+ # `:contacts` association:
19
+ #
20
+ # `accepts_nested_attributes_for :contacts`
21
+ #
22
+ # === Example 2: When association name and model name differ
23
+ # Suppose the `User` model has a `has_many :contacts` association
24
+ # pointing to a `UserContactInfo` model. You need to specify the
25
+ # model name.
26
+ #
27
+ # `accepts_nested_attributes_for :contacts, class_name: "UserAddress"`
28
+ #
29
+ # This macro defines:
30
+ # - `contacts_attributes=` — used to assign nested attributes,
31
+ # including support for `_destroy`
32
+ # - `contacts_attributes` — returns the current attributes of
33
+ # associated records
34
+ #
35
+ # @param association [Symbol] The association name. (e.g., `:contacts`).
36
+ # @param class_name [String, nil] Required if association reflection
37
+ # is needed to determine the associated model class (e.g. when the
38
+ # association name doesn't match the class name).
39
+ # @param reject_if [Proc, Symbol, nil] Used to skip building association
40
+ # records when the condition returns true.
41
+ def accepts_nested_attributes_for(
42
+ association,
43
+ class_name: nil,
44
+ reject_if: nil
45
+ )
46
+ destroy_values = [1, "1", "true", true]
47
+
48
+ should_destroy = ->(value) { destroy_values.include?(value) }
49
+
50
+ should_reject =
51
+ lambda do |attrs|
52
+ case reject_if
53
+ when Symbol
54
+ send(reject_if, attrs)
55
+ when Proc
56
+ reject_if.call(attrs)
57
+ else
58
+ false
59
+ end
60
+ end
61
+
62
+ assoc_class =
63
+ class_name&.constantize || association.to_s.classify.constantize
64
+
65
+ define_method(:"#{association}_attributes=") do |attributes|
66
+ result =
67
+ case attributes
68
+ when Hash
69
+ attrs = attributes.except(:_destroy)
70
+ unless should_destroy.call(attributes[:_destroy]) ||
71
+ should_reject.call(attrs)
72
+ assoc_class.new(attrs)
73
+ end
74
+ when Array
75
+ attributes.filter_map do |attrs|
76
+ unless should_destroy.call(attrs[:_destroy]) ||
77
+ should_reject.call(attrs)
78
+ assoc_class.new(attrs.except(:_destroy))
79
+ end
80
+ end
81
+ end
82
+
83
+ send(:"#{association}=", result)
84
+ end
85
+
86
+ define_method(:"#{association}_attributes") do
87
+ Array(send(association)).map(&:attributes)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -46,16 +46,20 @@ module Plutonium
46
46
  @resource_record = resource_class.new resource_params
47
47
 
48
48
  respond_to do |format|
49
- if resource_record!.save
49
+ if params[:pre_submit]
50
+ format.html { render :new, status: :unprocessable_entity }
51
+ elsif resource_record!.save
50
52
  format.html do
51
53
  redirect_to redirect_url_after_submit,
52
54
  notice: "#{resource_class.model_name.human} was successfully created."
53
55
  end
54
- format.any { render :show, status: :created, location: redirect_url_after_submit }
55
- else
56
- format.html do
57
- render :new, status: :unprocessable_entity
56
+ format.any do
57
+ render :show,
58
+ status: :created,
59
+ location: redirect_url_after_submit
58
60
  end
61
+ else
62
+ format.html { render :new, status: :unprocessable_entity }
59
63
  format.any do
60
64
  @errors = resource_record!.errors
61
65
  render "errors", status: :unprocessable_entity
@@ -79,17 +83,23 @@ module Plutonium
79
83
  authorize_current! resource_record!
80
84
  set_page_title "Update #{resource_record!.to_label.titleize}"
81
85
 
86
+ resource_record!.attributes = resource_params
87
+
82
88
  respond_to do |format|
83
- if resource_record!.update(resource_params)
89
+ if params[:pre_submit]
90
+ format.html { render :edit, status: :unprocessable_entity }
91
+ elsif resource_record!.save
84
92
  format.html do
85
- redirect_to redirect_url_after_submit, notice: "#{resource_class.model_name.human} was successfully updated.",
93
+ redirect_to redirect_url_after_submit,
94
+ notice:
95
+ "#{resource_class.model_name.human} was successfully updated.",
86
96
  status: :see_other
87
97
  end
88
- format.any { render :show, status: :ok, location: redirect_url_after_submit }
89
- else
90
- format.html do
91
- render :edit, status: :unprocessable_entity
98
+ format.any do
99
+ render :show, status: :ok, location: redirect_url_after_submit
92
100
  end
101
+ else
102
+ format.html { render :edit, status: :unprocessable_entity }
93
103
  format.any do
94
104
  @errors = resource_record!.errors
95
105
  render "errors", status: :unprocessable_entity
@@ -107,17 +117,21 @@ module Plutonium
107
117
 
108
118
  format.html do
109
119
  redirect_to redirect_url_after_destroy,
110
- notice: "#{resource_class.model_name.human} was successfully deleted."
120
+ notice:
121
+ "#{resource_class.model_name.human} was successfully deleted."
111
122
  end
112
123
  format.json { head :no_content }
113
124
  rescue ActiveRecord::InvalidForeignKey
114
125
  format.html do
115
126
  redirect_to resource_url_for(resource_record!),
116
- alert: "#{resource_class.model_name.human} is referenced by other records."
127
+ alert:
128
+ "#{resource_class.model_name.human} is referenced by other records."
117
129
  end
118
130
  format.any do
119
131
  @errors = ActiveModel::Errors.new resource_record!
120
- @errors.add :base, :existing_references, message: "is referenced by other records"
132
+ @errors.add :base,
133
+ :existing_references,
134
+ message: "is referenced by other records"
121
135
 
122
136
  render "errors", status: :unprocessable_entity
123
137
  end
@@ -38,12 +38,24 @@ module Plutonium
38
38
  def commit_interactive_record_action
39
39
  build_interactive_record_action_interaction
40
40
 
41
+ if params[:pre_submit]
42
+ respond_to do |format|
43
+ format.html do
44
+ render :interactive_record_action, status: :unprocessable_entity
45
+ end
46
+ end
47
+ return
48
+ end
49
+
41
50
  outcome = @interaction.call
42
- if outcome.success?
43
- outcome.to_response.process(self) do |value|
44
- respond_to do |format|
45
- return_url = redirect_url_after_action_on(resource_record!)
51
+
52
+ outcome.to_response.process(self) do |value|
53
+ respond_to do |format|
54
+ if outcome.success?
55
+ return_url = redirect_url_after_action_on(resource_class)
56
+
46
57
  format.any { redirect_to return_url, status: :see_other }
58
+
47
59
  if helpers.current_turbo_frame == "modal"
48
60
  format.turbo_stream do
49
61
  render turbo_stream: [
@@ -51,18 +63,16 @@ module Plutonium
51
63
  ]
52
64
  end
53
65
  end
54
- end
55
- end
56
- else
57
- outcome.to_response.process(self) do
58
- respond_to do |format|
66
+ else
59
67
  format.html do
60
68
  render :interactive_record_action, status: :unprocessable_entity
61
69
  end
70
+
62
71
  format.any do
63
72
  @errors = @interaction.errors
64
73
  render "errors", status: :unprocessable_entity
65
74
  end
75
+
66
76
  if helpers.current_turbo_frame == "modal"
67
77
  format.turbo_stream do
68
78
  render turbo_stream: [
@@ -92,12 +102,24 @@ module Plutonium
92
102
  skip_verify_current_authorized_scope!
93
103
  build_interactive_resource_action_interaction
94
104
 
105
+ if params[:pre_submit]
106
+ respond_to do |format|
107
+ format.html do
108
+ render :interactive_resource_action, status: :unprocessable_entity
109
+ end
110
+ end
111
+ return
112
+ end
113
+
95
114
  outcome = @interaction.call
96
- if outcome.success?
97
- outcome.to_response.process(self) do |value|
98
- respond_to do |format|
115
+
116
+ outcome.to_response.process(self) do |value|
117
+ respond_to do |format|
118
+ if outcome.success?
99
119
  return_url = redirect_url_after_action_on(resource_class)
120
+
100
121
  format.any { redirect_to return_url, status: :see_other }
122
+
101
123
  if helpers.current_turbo_frame == "modal"
102
124
  format.turbo_stream do
103
125
  render turbo_stream: [
@@ -105,18 +127,16 @@ module Plutonium
105
127
  ]
106
128
  end
107
129
  end
108
- end
109
- end
110
- else
111
- outcome.to_response.process(self) do
112
- respond_to do |format|
130
+ else
113
131
  format.html do
114
- render :interactive_record_action, status: :unprocessable_entity
132
+ render :interactive_resource_action, status: :unprocessable_entity
115
133
  end
134
+
116
135
  format.any do
117
136
  @errors = @interaction.errors
118
137
  render "errors", status: :unprocessable_entity
119
138
  end
139
+
120
140
  if helpers.current_turbo_frame == "modal"
121
141
  format.turbo_stream do
122
142
  render turbo_stream: [
@@ -59,11 +59,11 @@ module Plutonium
59
59
  def query_based_on_association(assoc, record)
60
60
  case assoc.macro
61
61
  when :has_one
62
- joins(assoc.name).where(assoc.name => {record.class.primary_key => record.id})
62
+ joins(assoc.name).where(assoc.name => {record.class.primary_key => record})
63
63
  when :belongs_to
64
64
  where(assoc.name => record)
65
65
  when :has_many
66
- joins(assoc.name).where(assoc.klass.table_name => record)
66
+ joins(assoc.name).where(assoc.name => {record.class.primary_key => record})
67
67
  else
68
68
  raise NotImplementedError, "associated_with->##{assoc.macro}"
69
69
  end
@@ -84,6 +84,12 @@ module Plutonium
84
84
 
85
85
  @form_action ||= url_for(object, action: object.new_record? ? :create : :update)
86
86
  end
87
+
88
+ def initialize_attributes
89
+ super
90
+
91
+ attributes["data-controller"] = "form"
92
+ end
87
93
  end
88
94
  end
89
95
  end
@@ -8,6 +8,8 @@ module Plutonium
8
8
 
9
9
  attr_reader :resource_fields, :resource_definition
10
10
 
11
+ alias_method :record, :object
12
+
11
13
  def initialize(*, resource_fields:, resource_definition:, **, &)
12
14
  super(*, **, &)
13
15
  @resource_fields = resource_fields
@@ -59,12 +61,23 @@ module Plutonium
59
61
  input_definition = definition.defined_inputs[name] || {}
60
62
  input_options = input_definition[:options] || {}
61
63
 
64
+ condition = input_options[:condition] || field_options[:condition]
65
+ return if condition && !instance_exec(&condition)
66
+
62
67
  tag = input_options[:as] || field_options[:as]
63
- tag_attributes = input_options.except(:wrapper, :as)
64
- tag_block = input_definition[:block] || ->(f) {
65
- tag ||= f.inferred_field_component
66
- f.send(:"#{tag}_tag", **tag_attributes)
67
- }
68
+ tag_attributes =
69
+ input_options.except(:wrapper, :as, :pre_submit, :condition)
70
+ if input_options[:pre_submit]
71
+ tag_attributes[
72
+ "data-action"
73
+ ] = "change->form#preSubmit"
74
+ end
75
+ tag_block =
76
+ input_definition[:block] ||
77
+ ->(f) do
78
+ tag ||= f.inferred_field_component
79
+ f.send(:"#{tag}_tag", **tag_attributes)
80
+ end
68
81
 
69
82
  wrapper_options = input_options[:wrapper] || {}
70
83
  if !wrapper_options[:class] || !wrapper_options[:class].include?("col-span")
@@ -73,8 +86,10 @@ module Plutonium
73
86
  wrapper_options[:class] = tokens("col-span-full", wrapper_options[:class])
74
87
  end
75
88
 
76
- field_options = field_options.except(:as)
77
- render form.field(name, **field_options).wrapped(**wrapper_options) do |f|
89
+ field_options = field_options.except(:as, :condition)
90
+ render form.field(name, **field_options).wrapped(
91
+ **wrapper_options
92
+ ) do |f|
78
93
  render instance_exec(f, &tag_block)
79
94
  end
80
95
  end
@@ -37,7 +37,7 @@ module Plutonium
37
37
  # file
38
38
  # file: "w-full border rounded-md shadow-sm font-medium text-sm dark:bg-gray-700 focus:outline-none",
39
39
  # hint themes
40
- hint: "mt-2 text-sm text-gray-500 dark:text-gray-200",
40
+ hint: "mt-2 text-sm text-gray-500 dark:text-gray-200 whitespace-pre",
41
41
  # error themes
42
42
  error: "mt-2 text-sm text-red-600 dark:text-red-500",
43
43
  # button themes
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.21.0"
2
+ VERSION = "0.21.1"
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.1",
3
+ "version": "0.4.2",
4
4
  "description": "Core assets for the Plutonium gem",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
@@ -1,11 +1,27 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import debounce from "lodash.debounce";
3
2
 
4
3
  // Connects to data-controller="form"
5
4
  export default class extends Controller {
6
5
  connect() {
7
6
  }
7
+
8
+ preSubmit() {
9
+ // Create a hidden input field
10
+ const hiddenField = document.createElement('input');
11
+ hiddenField.type = 'hidden';
12
+ hiddenField.name = 'pre_submit';
13
+ hiddenField.value = 'true';
14
+
15
+ // Append it to the form
16
+ this.element.appendChild(hiddenField);
8
17
 
18
+ // Skip validation by setting novalidate attribute
19
+ this.element.setAttribute('novalidate', '');
20
+
21
+ // Submit the form
22
+ this.submit();
23
+ }
24
+
9
25
  submit() {
10
26
  this.element.requestSubmit()
11
27
  }
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.21.0
4
+ version: 0.21.1
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-04-01 00:00:00.000000000 Z
11
+ date: 2025-04-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: zeitwerk
@@ -750,6 +750,7 @@ files:
750
750
  - lib/plutonium/interaction/README.md
751
751
  - lib/plutonium/interaction/base.rb
752
752
  - lib/plutonium/interaction/concerns/workflow_dsl.rb
753
+ - lib/plutonium/interaction/nested_attributes.rb
753
754
  - lib/plutonium/interaction/outcome.rb
754
755
  - lib/plutonium/interaction/response/base.rb
755
756
  - lib/plutonium/interaction/response/failure.rb