plutonium 0.25.2 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -371,7 +371,7 @@ class PostDefinition < Plutonium::Resource::Definition
371
371
 
372
372
  # Add filters to the sidebar
373
373
  filter :published, with: ->(scope, value) { value ? scope.where.not(published_at: nil) : scope.where(published_at: nil) }, as: :boolean
374
- filter :user, as: :select, collection: -> { User.pluck(:name, :id) }
374
+ filter :user, as: :select, choices: User.pluck(:name, :id)
375
375
 
376
376
  # Define named scopes that appear as buttons
377
377
  scope :all
@@ -71,7 +71,7 @@ class PostDefinition < Plutonium::Resource::Definition
71
71
  # Only override when you need custom input behavior:
72
72
  input :content, as: :rich_text # Override text -> rich_text
73
73
  input :title, placeholder: "Enter title" # Add placeholder
74
- input :category, as: :select, collection: %w[Tech Business] # Add options
74
+ input :category, as: :select, choices: %w[Tech Business] # Add options
75
75
  input :published_at, as: :date # Override datetime -> date only
76
76
  end
77
77
  ```
@@ -158,8 +158,8 @@ end
158
158
  ```ruby
159
159
  class PostDefinition < Plutonium::Resource::Definition
160
160
  # Provide options for select inputs
161
- input :category, as: :select, collection: %w[Tech Business Lifestyle]
162
- input :author, as: :select, collection: -> { User.active.pluck(:name, :id) }
161
+ input :category, as: :select, choices: %w[Tech Business Lifestyle]
162
+ input :author, as: :select, choices: -> { User.active.pluck(:name, :id) }
163
163
  end
164
164
  ```
165
165
 
@@ -177,7 +177,7 @@ class PostDefinition < Plutonium::Resource::Definition
177
177
 
178
178
  # Use `pre_submit` to create dynamic forms where inputs appear based on other inputs.
179
179
  input :send_notifications, as: :boolean, pre_submit: true
180
- input :notification_channel, as: :select, collection: %w[Email SMS],
180
+ input :notification_channel, as: :select, choices: %w[Email SMS],
181
181
  condition: -> { object.send_notifications? }
182
182
 
183
183
  # Show debug fields only in development
@@ -280,6 +280,7 @@ The block syntax offers more control over rendering, allowing for custom compone
280
280
  - Building complex layouts with multiple components or custom HTML (for `display` only).
281
281
  - You need conditional logic to determine which component to render.
282
282
  - You need to call specific form builder methods with custom logic (for `input`).
283
+ - **You need dynamic choices for select inputs** (since `choices:` option only accepts static arrays).
283
284
 
284
285
  ::: code-group
285
286
  ```ruby [Custom Display Components]
@@ -302,6 +303,29 @@ input :birth_date do |f|
302
303
  end
303
304
  end
304
305
  ```
306
+ ```ruby [Dynamic Choices for Select Inputs]
307
+ # Dynamic choices based on object state
308
+ input :widget_type do |f|
309
+ choices = case object.question_type
310
+ when nil, ""
311
+ []
312
+ when "text"
313
+ ["input", "textarea"]
314
+ when "choice"
315
+ ["radio", "select", "checkbox"]
316
+ when "scale"
317
+ ["slider", "radio_scale", "select_scale"]
318
+ when "date"
319
+ ["date_picker", "datetime_picker"]
320
+ when "boolean"
321
+ ["checkbox", "toggle", "yes_no"]
322
+ else
323
+ Question::WIDGET_TYPES
324
+ end
325
+
326
+ f.select_tag choices: choices
327
+ end
328
+ ```
305
329
  ```ruby [Conditional Rendering]
306
330
  # Conditional display based on value
307
331
  display :metrics do |field|
@@ -829,32 +853,43 @@ display :content,
829
853
  condition: -> { current_user.can_see_content? }
830
854
  ```
831
855
 
832
- ### Collection Options (for selects)
856
+ ### Choices Options (for selects)
833
857
  ```ruby
834
- input :category, as: :select, collection: %w[Tech Business Lifestyle]
835
- input :author, as: :select, collection: -> { User.active.pluck(:name, :id) }
858
+ # Static choices
859
+ input :category, as: :select, choices: %w[Tech Business Lifestyle]
836
860
 
837
- # Collection procs are executed in the form rendering context
838
- # and have access to current_user and other helpers:
839
- input :team_members, as: :select, collection: -> {
840
- current_user.organization.users.active.pluck(:name, :id)
841
- }
861
+ # Dynamic choices require block form
862
+ input :author do |f|
863
+ choices = User.active.pluck(:name, :id)
864
+ f.select_tag choices: choices
865
+ end
842
866
 
843
- # You can also access the form object being edited:
844
- input :related_posts, as: :select, collection: -> {
845
- Post.where.not(id: object.id).published.pluck(:title, :id) if object.persisted?
846
- }
867
+ # Dynamic choices with access to context
868
+ input :team_members do |f|
869
+ choices = current_user.organization.users.active.pluck(:name, :id)
870
+ f.select_tag choices: choices
871
+ end
872
+
873
+ # Dynamic choices based on object state
874
+ input :related_posts do |f|
875
+ choices = if object.persisted?
876
+ Post.where.not(id: object.id).published.pluck(:title, :id)
877
+ else
878
+ []
879
+ end
880
+ f.select_tag choices: choices
881
+ end
847
882
  ```
848
883
 
849
- ::: tip Collection Context
850
- Collection procs are evaluated in the form rendering context, which means they have access to:
884
+ ::: tip Dynamic Choices Context
885
+ When using block forms for dynamic choices, you have access to:
851
886
  - `current_user` - The authenticated user
852
887
  - `current_parent` - Parent record for nested resources
853
888
  - `object` - The record being edited (in edit forms)
854
889
  - `request` and `params` - Request information
855
890
  - All helper methods available in the form context
856
891
 
857
- This is the same context as `condition` procs, allowing for dynamic, user-specific collections.
892
+ Block forms are required for dynamic choices since the `choices:` option only accepts static arrays.
858
893
  :::
859
894
 
860
895
  ### File Upload Options
@@ -868,7 +903,7 @@ input :documents, as: :file, multiple: true,
868
903
  ## Dynamic Configuration & Policies
869
904
 
870
905
  ::: danger IMPORTANT
871
- Definitions are instantiated outside the controller context, which means **`current_user` and other controller methods are NOT available** within the definition file itself. However, `condition` and `collection` procs ARE evaluated in the rendering context where `current_user` and the record (`object`) are available.
906
+ Definitions are instantiated outside the controller context, which means **`current_user` and other controller methods are NOT available** within the definition file itself. However, `condition` procs and input blocks ARE evaluated in the rendering context where `current_user` and the record (`object`) are available.
872
907
  :::
873
908
 
874
909
  The `condition` option configures **if an input is rendered**. It does not control if a field's *value* is accessible. For that, you must use policies.
@@ -65,7 +65,7 @@ column :published_at, as: :datetime
65
65
  column :is_active, as: :boolean
66
66
  column :priority, as: :badge
67
67
  column :profile_picture, as: :attachment
68
- column :metadata, as: :json
68
+ column :metadata, as: :key_value_store
69
69
  ```
70
70
 
71
71
  ### Alignment
@@ -90,7 +90,7 @@ class PostDefinition < Plutonium::Resource::Definition
90
90
 
91
91
  # Only declare fields when you want to override defaults:
92
92
  field :content, as: :rich_text # Override text → rich_text
93
- field :status, as: :select, collection: %w[draft published archived]
93
+ field :status, as: :select, choices: %w[draft published archived]
94
94
 
95
95
  # Display customization (show/index pages)
96
96
  display :title, as: :string
@@ -119,7 +119,7 @@ class PostDefinition < Plutonium::Resource::Definition
119
119
  # Input configuration (new/edit forms)
120
120
  input :title, placeholder: "Enter post title" # No need for as: :string (auto-detected)
121
121
  input :content, as: :rich_text
122
- input :category, as: :select, collection: %w[Tech Business Lifestyle]
122
+ input :category, as: :select, choices: %w[Tech Business Lifestyle]
123
123
 
124
124
  # New: Custom component classes
125
125
  input :color_picker, as: ColorPickerComponent
@@ -239,19 +239,32 @@ display :content,
239
239
  condition: -> { Rails.env.development? } # Cosmetic condition only
240
240
  ```
241
241
 
242
- #### Collection Options (for selects)
242
+ #### Choices Options (for selects)
243
243
  ```ruby
244
- input :category, as: :select, collection: %w[Tech Business Lifestyle]
245
- input :author, as: :select, collection: -> { User.active.pluck(:name, :id) }
246
-
247
- # Collection procs are executed in form rendering context with access to:
248
- # current_user, current_parent, object, request, params, and helpers
249
- input :team_members, as: :select, collection: -> {
250
- current_user.organization.users.active.pluck(:name, :id)
251
- }
252
- input :related_posts, as: :select, collection: -> {
253
- Post.where.not(id: object.id).published.pluck(:title, :id) if object.persisted?
254
- }
244
+ # Static choices
245
+ input :category, as: :select, choices: %w[Tech Business Lifestyle]
246
+
247
+ # Dynamic choices require block form
248
+ input :author do |f|
249
+ choices = User.active.pluck(:name, :id)
250
+ f.select_tag choices: choices
251
+ end
252
+
253
+ # Dynamic choices with access to context
254
+ input :team_members do |f|
255
+ choices = current_user.organization.users.active.pluck(:name, :id)
256
+ f.select_tag choices: choices
257
+ end
258
+
259
+ # Dynamic choices based on object state
260
+ input :related_posts do |f|
261
+ choices = if object.persisted?
262
+ Post.where.not(id: object.id).published.pluck(:title, :id)
263
+ else
264
+ []
265
+ end
266
+ f.select_tag choices: choices
267
+ end
255
268
  ```
256
269
 
257
270
  #### File Upload Options
@@ -79,13 +79,13 @@ module Plutonium
79
79
  url_args[:id] = element.to_param unless resource_route_config[:route_type] == :resource
80
80
  url_args[:action] ||= :show
81
81
  else
82
- url_args[element.model_name.singular_route_key.to_sym] = element.to_param
82
+ url_args[element.model_name.singular.to_sym] = element.to_param
83
83
  end
84
84
  end
85
85
  end
86
86
  url_args[:controller] = "/#{controller_chain.join("::").underscore}"
87
87
 
88
- url_args[:"#{parent.model_name.singular_route_key}_id"] = parent.to_param if parent.present?
88
+ url_args[:"#{parent.model_name.singular}_id"] = parent.to_param if parent.present?
89
89
  if scoped_to_entity? && scoped_entity_strategy == :path
90
90
  url_args[scoped_entity_param_key] = current_scoped_entity
91
91
  end
@@ -43,6 +43,11 @@ module Plutonium
43
43
  current_engine.scoped_entity_param_key
44
44
  end
45
45
 
46
+ def scoped_entity_route_key
47
+ ensure_legal_entity_scoping_method_access!(__method__)
48
+ current_engine.scoped_entity_route_key
49
+ end
50
+
46
51
  # Returns the class of the scoped entity.
47
52
  #
48
53
  # @return [Class] the scoped entity class
@@ -5,12 +5,13 @@ module Plutonium
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  class_methods do
8
- attr_reader :scoped_entity_class, :scoped_entity_strategy, :scoped_entity_param_key
8
+ attr_reader :scoped_entity_class, :scoped_entity_strategy, :scoped_entity_param_key, :scoped_entity_route_key
9
9
 
10
- def scope_to_entity(entity_class, strategy: :path, param_key: nil)
10
+ def scope_to_entity(entity_class, strategy: :path, param_key: nil, route_key: nil)
11
11
  @scoped_entity_class = entity_class
12
12
  @scoped_entity_strategy = strategy
13
13
  @scoped_entity_param_key = param_key || entity_class.model_name.singular_route_key.to_sym
14
+ @scoped_entity_route_key = route_key || entity_class.model_name.singular.to_sym
14
15
  end
15
16
 
16
17
  def scoped_to_entity?
@@ -44,7 +44,7 @@ module Plutonium
44
44
  def route_key_lookup
45
45
  freeze
46
46
  resources.to_h do |resource|
47
- [resource.model_name.singular_route_key.to_sym, resource]
47
+ [resource.model_name.singular.to_sym, resource]
48
48
  end
49
49
  end
50
50
  # memoize_unless_reloading :route_key_lookup
@@ -91,12 +91,18 @@ module Plutonium
91
91
  # @param [Symbol] name The name of the nested resource field
92
92
  # @raise [ArgumentError] if the nested input definition is missing required configuration
93
93
  def render_nested_resource_field(name)
94
+ nested_input_definition = resource_definition.defined_nested_inputs[name]
95
+ condition = nested_input_definition[:options]&.fetch(:condition, nil)
96
+ if condition && !instance_exec(&condition)
97
+ return
98
+ end
99
+
94
100
  context = NestedFieldContext.new(
95
101
  name: name,
96
102
  definition: build_nested_fields_definition(name),
97
103
  resource_class: resource_class,
98
104
  resource_definition: resource_definition,
99
- object_class: resource_definition.defined_nested_inputs[name][:options]&.fetch(:object_class, nil)
105
+ object_class: nested_input_definition[:options]&.fetch(:object_class, nil)
100
106
  )
101
107
 
102
108
  render_nested_field_container(context) do
@@ -171,7 +177,7 @@ module Plutonium
171
177
  end
172
178
 
173
179
  def render_template_for_nested_fields(context, options, nesting_method:)
174
- template_tag data_nested_resource_form_fields_target: "template" do
180
+ template data_nested_resource_form_fields_target: "template" do
175
181
  send(nesting_method, context.name, as: context.nested_fields_input_param, **options, template: true) do |nested|
176
182
  render_nested_fields_fieldset(nested, context)
177
183
  end
@@ -32,6 +32,29 @@ module Plutonium
32
32
  transition-opacity duration-300 ease-in-out",
33
33
  data: {controller: "remote-modal"}
34
34
  ) do
35
+ # Close button
36
+ button(
37
+ type: "button",
38
+ class: "absolute top-4 right-4 p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-200",
39
+ data: {action: "remote-modal#close"},
40
+ "aria-label": "Close dialog"
41
+ ) do
42
+ svg(
43
+ class: "w-5 h-5",
44
+ fill: "none",
45
+ stroke: "currentColor",
46
+ viewBox: "0 0 24 24",
47
+ xmlns: "http://www.w3.org/2000/svg"
48
+ ) do |s|
49
+ s.path(
50
+ stroke_linecap: "round",
51
+ stroke_linejoin: "round",
52
+ stroke_width: "2",
53
+ d: "M6 18L18 6M6 6l12 12"
54
+ )
55
+ end
56
+ end
57
+
35
58
  render_page_header
36
59
  render partial("interactive_action_form")
37
60
  end
@@ -60,7 +60,7 @@ module Plutonium
60
60
  column_options = column_definition[:options] || {}
61
61
 
62
62
  # Check for conditional rendering
63
- condition = column_options[:condition] || display_options[:condition] || field_options[:condition]
63
+ condition = column_options[:condition]
64
64
  conditionally_hidden = condition && !instance_exec(&condition)
65
65
  next if conditionally_hidden
66
66
 
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.25.2"
2
+ VERSION = "0.26.0"
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.4",
3
+ "version": "0.4.8",
4
4
  "description": "Core assets for the Plutonium gem",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
@@ -309,3 +309,40 @@
309
309
  .ss-main.ss-valid .ss-values .ss-placeholder {
310
310
  @apply text-green-700 dark:text-green-500;
311
311
  }
312
+
313
+ /* Modal-specific styles for SlimSelect dropdown */
314
+ .ss-dropdown-container {
315
+ position: absolute;
316
+ z-index: 9999;
317
+ inset: 40% 0px auto;
318
+ }
319
+
320
+ .ss-dropdown-container .ss-content {
321
+ position: static !important;
322
+ transform: none !important;
323
+ width: 100% !important;
324
+ border-radius: 0 !important;
325
+ margin: 0 !important;
326
+ pointer-events: none !important; /* Disabled by default */
327
+ }
328
+
329
+ /* When active (dropdown is expanded), enable pointer events */
330
+ .ss-dropdown-container.ss-active .ss-content {
331
+ pointer-events: auto !important;
332
+ }
333
+
334
+ .ss-dropdown-container .ss-list {
335
+ max-height: 250px !important;
336
+ overflow-y: auto !important;
337
+ }
338
+
339
+ /* Ensure the dropdown doesn't block other elements when closed */
340
+ .ss-dropdown-container:not(:has(.ss-content)),
341
+ .ss-dropdown-container:not(.ss-active) {
342
+ pointer-events: none !important;
343
+ }
344
+
345
+ /* Prevent interaction with closed dropdown */
346
+ .ss-dropdown-container:not(.ss-active) * {
347
+ pointer-events: none !important;
348
+ }
@@ -2,23 +2,30 @@ import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  // Connects to data-controller="remote-modal"
4
4
  export default class extends Controller {
5
- connect() {
6
- // Store original scroll position
7
- this.originalScrollPosition = window.scrollY;
5
+ connect() {
6
+ // Store original scroll position
7
+ this.originalScrollPosition = window.scrollY;
8
8
 
9
- // Show the modal
10
- this.element.showModal();
11
- // Add close event listener
12
- this.element.addEventListener('close', this.handleClose.bind(this));
13
- }
9
+ // Show the modal
10
+ this.element.showModal();
11
+ // Add close event listener
12
+ this.element.addEventListener("close", this.handleClose.bind(this));
13
+ }
14
14
 
15
- disconnect() {
16
- // Clean up event listener when controller is disconnected
17
- this.element.removeEventListener('close', this.handleClose);
18
- }
15
+ close() {
16
+ // Close the modal
17
+ this.element.close();
18
+ // Restore the original scroll position
19
+ window.scrollTo(0, this.originalScrollPosition);
20
+ }
19
21
 
20
- handleClose() {
21
- // Restore the original scroll position after dialog closes
22
- window.scrollTo(0, this.originalScrollPosition);
23
- }
24
- }
22
+ disconnect() {
23
+ // Clean up event listener when controller is disconnected
24
+ this.element.removeEventListener("close", this.handleClose);
25
+ }
26
+
27
+ handleClose() {
28
+ // Restore the original scroll position after dialog closes
29
+ window.scrollTo(0, this.originalScrollPosition);
30
+ }
31
+ }