plutonium 0.49.0 → 0.50.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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-definition/SKILL.md +87 -2
  3. data/.claude/skills/plutonium-installation/SKILL.md +6 -0
  4. data/.claude/skills/plutonium-invites/SKILL.md +41 -0
  5. data/.claude/skills/plutonium-views/SKILL.md +59 -0
  6. data/CHANGELOG.md +27 -0
  7. data/app/assets/plutonium.css +2 -2
  8. data/app/assets/plutonium.js +404 -25
  9. data/app/assets/plutonium.js.map +4 -4
  10. data/app/assets/plutonium.min.js +45 -45
  11. data/app/assets/plutonium.min.js.map +4 -4
  12. data/app/views/plutonium/_flash.html.erb +1 -1
  13. data/app/views/plutonium/_resource_header.html.erb +4 -4
  14. data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
  15. data/app/views/resource/_resource_grid.html.erb +1 -0
  16. data/config/brakeman.ignore +25 -2
  17. data/docs/guides/user-invites.md +64 -0
  18. data/docs/reference/definition/actions.md +14 -1
  19. data/docs/reference/definition/index.md +58 -0
  20. data/docs/reference/views/index.md +43 -0
  21. data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md +1487 -0
  22. data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md.tasks.json +15 -0
  23. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md +841 -0
  24. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
  25. data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -0
  26. data/gemfiles/rails_7.gemfile.lock +1 -1
  27. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  28. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  29. data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
  30. data/lib/generators/pu/core/update/update_generator.rb +20 -0
  31. data/lib/generators/pu/invites/install_generator.rb +136 -35
  32. data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +8 -2
  33. data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +7 -1
  34. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +4 -4
  35. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +9 -4
  36. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +2 -2
  37. data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +1 -1
  38. data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +8 -8
  39. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +13 -4
  40. data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +3 -3
  41. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +1 -1
  42. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +1 -1
  43. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +2 -2
  44. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +4 -4
  45. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +4 -4
  46. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +5 -1
  47. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
  48. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +5 -1
  49. data/lib/plutonium/action/base.rb +44 -1
  50. data/lib/plutonium/action/interactive.rb +1 -1
  51. data/lib/plutonium/configuration.rb +4 -0
  52. data/lib/plutonium/definition/actions.rb +3 -0
  53. data/lib/plutonium/definition/base.rb +8 -0
  54. data/lib/plutonium/definition/metadata.rb +40 -0
  55. data/lib/plutonium/definition/views.rb +94 -0
  56. data/lib/plutonium/helpers/turbo_helper.rb +1 -1
  57. data/lib/plutonium/interaction/response/redirect.rb +1 -1
  58. data/lib/plutonium/invites/concerns/invite_token.rb +11 -3
  59. data/lib/plutonium/invites/concerns/invite_user.rb +13 -4
  60. data/lib/plutonium/invites/controller.rb +14 -1
  61. data/lib/plutonium/invites/pending_invite_check.rb +37 -28
  62. data/lib/plutonium/query/base.rb +8 -0
  63. data/lib/plutonium/query/filters/association.rb +30 -8
  64. data/lib/plutonium/query/filters/boolean.rb +5 -0
  65. data/lib/plutonium/resource/controllers/interactive_actions.rb +13 -9
  66. data/lib/plutonium/resource/controllers/presentable.rb +11 -2
  67. data/lib/plutonium/resource/definition.rb +42 -0
  68. data/lib/plutonium/resource/policy.rb +23 -8
  69. data/lib/plutonium/resource/query_object.rb +64 -6
  70. data/lib/plutonium/testing/resource_definition.rb +2 -2
  71. data/lib/plutonium/ui/action_button.rb +4 -2
  72. data/lib/plutonium/ui/component/kit.rb +12 -0
  73. data/lib/plutonium/ui/display/base.rb +3 -1
  74. data/lib/plutonium/ui/display/resource.rb +109 -25
  75. data/lib/plutonium/ui/display/theme.rb +2 -1
  76. data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
  77. data/lib/plutonium/ui/empty_card.rb +1 -1
  78. data/lib/plutonium/ui/form/base.rb +29 -1
  79. data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
  80. data/lib/plutonium/ui/form/components/resource_select.rb +79 -1
  81. data/lib/plutonium/ui/form/components/secure_association.rb +7 -2
  82. data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
  83. data/lib/plutonium/ui/form/resource.rb +48 -9
  84. data/lib/plutonium/ui/form/theme.rb +1 -1
  85. data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
  86. data/lib/plutonium/ui/grid/card.rb +235 -0
  87. data/lib/plutonium/ui/grid/resource.rb +149 -0
  88. data/lib/plutonium/ui/layout/base.rb +37 -1
  89. data/lib/plutonium/ui/layout/header.rb +1 -2
  90. data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
  91. data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
  92. data/lib/plutonium/ui/layout/sidebar.rb +12 -24
  93. data/lib/plutonium/ui/layout/topbar.rb +100 -0
  94. data/lib/plutonium/ui/modal/base.rb +109 -0
  95. data/lib/plutonium/ui/modal/centered.rb +21 -0
  96. data/lib/plutonium/ui/modal/slideover.rb +26 -0
  97. data/lib/plutonium/ui/page/base.rb +25 -6
  98. data/lib/plutonium/ui/page/edit.rb +13 -1
  99. data/lib/plutonium/ui/page/index.rb +40 -1
  100. data/lib/plutonium/ui/page/interactive_action.rb +8 -39
  101. data/lib/plutonium/ui/page/new.rb +13 -1
  102. data/lib/plutonium/ui/page/show.rb +8 -1
  103. data/lib/plutonium/ui/page_header.rb +8 -13
  104. data/lib/plutonium/ui/panel.rb +10 -19
  105. data/lib/plutonium/ui/sidebar_menu.rb +2 -25
  106. data/lib/plutonium/ui/tab_list.rb +29 -7
  107. data/lib/plutonium/ui/table/base.rb +106 -0
  108. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
  109. data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
  110. data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
  111. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
  112. data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
  113. data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
  114. data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
  115. data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
  116. data/lib/plutonium/ui/table/resource.rb +158 -89
  117. data/lib/plutonium/ui/table/theme.rb +14 -5
  118. data/lib/plutonium/version.rb +1 -1
  119. data/lib/plutonium.rb +6 -0
  120. data/package.json +1 -1
  121. data/src/css/components.css +304 -131
  122. data/src/css/tokens.css +101 -85
  123. data/src/js/controllers/autosubmit_controller.js +24 -0
  124. data/src/js/controllers/bulk_actions_controller.js +15 -16
  125. data/src/js/controllers/capture_url_controller.js +14 -0
  126. data/src/js/controllers/filter_panel_controller.js +77 -19
  127. data/src/js/controllers/flatpickr_controller.js +23 -0
  128. data/src/js/controllers/frame_navigator_controller.js +34 -6
  129. data/src/js/controllers/icon_rail_controller.js +22 -0
  130. data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
  131. data/src/js/controllers/register_controllers.js +16 -0
  132. data/src/js/controllers/resource_tab_list_controller.js +56 -3
  133. data/src/js/controllers/row_click_controller.js +21 -0
  134. data/src/js/controllers/sidebar_controller.js +28 -1
  135. data/src/js/controllers/table_column_menu_controller.js +43 -0
  136. data/src/js/controllers/table_header_controller.js +16 -0
  137. data/src/js/controllers/view_switcher_controller.js +29 -0
  138. metadata +33 -3
@@ -29,7 +29,7 @@ module Plutonium
29
29
  # GET /resources/1/record_actions/:interactive_action
30
30
  def interactive_record_action
31
31
  build_interactive_record_action_interaction
32
- render :interactive_record_action, layout: modal_layout, formats: [:html]
32
+ render :interactive_record_action, formats: [:html], **modal_render_options
33
33
  end
34
34
 
35
35
  # POST /resources/1/record_actions/:interactive_action
@@ -62,7 +62,7 @@ module Plutonium
62
62
  end
63
63
  else
64
64
  format.any(:html, :turbo_stream) do
65
- render :interactive_record_action, layout: modal_layout, formats: [:html], status: :unprocessable_content
65
+ render :interactive_record_action, formats: [:html], content_type: "text/html", **modal_render_options, status: :unprocessable_content
66
66
  end
67
67
  format.any do
68
68
  @errors = @interaction.errors
@@ -77,7 +77,7 @@ module Plutonium
77
77
  def interactive_resource_action
78
78
  skip_verify_current_authorized_scope!
79
79
  build_interactive_resource_action_interaction
80
- render :interactive_resource_action, layout: modal_layout, formats: [:html]
80
+ render :interactive_resource_action, formats: [:html], **modal_render_options
81
81
  end
82
82
 
83
83
  # POST /resources/resource_actions/:interactive_action
@@ -111,7 +111,7 @@ module Plutonium
111
111
  end
112
112
  else
113
113
  format.any(:html, :turbo_stream) do
114
- render :interactive_resource_action, layout: modal_layout, formats: [:html], status: :unprocessable_content
114
+ render :interactive_resource_action, formats: [:html], content_type: "text/html", **modal_render_options, status: :unprocessable_content
115
115
  end
116
116
  format.any do
117
117
  @errors = @interaction.errors
@@ -125,7 +125,7 @@ module Plutonium
125
125
  # GET /resources/bulk_actions/:interactive_action?ids[]=1&ids[]=2
126
126
  def interactive_bulk_action
127
127
  build_interactive_bulk_action_interaction
128
- render :interactive_bulk_action, layout: modal_layout, formats: [:html]
128
+ render :interactive_bulk_action, formats: [:html], **modal_render_options
129
129
  end
130
130
 
131
131
  # POST /resources/bulk_actions/:interactive_action?ids[]=1&ids[]=2
@@ -158,7 +158,7 @@ module Plutonium
158
158
  end
159
159
  else
160
160
  format.any(:html, :turbo_stream) do
161
- render :interactive_bulk_action, layout: modal_layout, formats: [:html], status: :unprocessable_content
161
+ render :interactive_bulk_action, formats: [:html], content_type: "text/html", **modal_render_options, status: :unprocessable_content
162
162
  end
163
163
  format.any do
164
164
  @errors = @interaction.errors
@@ -171,9 +171,13 @@ module Plutonium
171
171
 
172
172
  private
173
173
 
174
- # Returns false for modal requests (skip layout), nil otherwise (use default layout)
175
- def modal_layout
176
- helpers.current_turbo_frame.present? ? false : nil
174
+ # Render options for modal-aware actions. Returns `{ layout: false }` for
175
+ # turbo-frame requests so the bare frame is rendered, and an empty hash
176
+ # for top-level requests so the controller's default layout proc applies.
177
+ # (Passing `layout: nil` explicitly is treated as "no layout" by Rails,
178
+ # which is why we omit the key entirely on the default path.)
179
+ def modal_render_options
180
+ helpers.current_turbo_frame.present? ? {layout: false} : {}
177
181
  end
178
182
 
179
183
  def current_interactive_action
@@ -5,7 +5,7 @@ module Plutonium
5
5
  extend ActiveSupport::Concern
6
6
 
7
7
  included do
8
- helper_method :build_form, :build_detail, :build_collection
8
+ helper_method :build_form, :build_detail, :build_collection, :build_grid_collection
9
9
  end
10
10
 
11
11
  private
@@ -82,8 +82,17 @@ module Plutonium
82
82
  current_definition.collection_class.new(@resource_records, resource_fields: presentable_attributes, resource_definition: current_definition)
83
83
  end
84
84
 
85
+ def build_grid_collection
86
+ current_definition.grid_class.new(@resource_records, resource_fields: presentable_attributes, resource_definition: current_definition)
87
+ end
88
+
85
89
  def build_detail
86
- current_definition.detail_class.new(resource_record!, resource_fields: presentable_attributes, resource_associations: permitted_associations, resource_definition: current_definition)
90
+ current_definition.detail_class.new(
91
+ resource_record!,
92
+ resource_fields: presentable_attributes,
93
+ resource_associations: permitted_associations,
94
+ resource_definition: current_definition
95
+ )
87
96
  end
88
97
 
89
98
  def build_form(record = resource_record!, action: action_name, form_action: nil, **)
@@ -1,6 +1,48 @@
1
1
  module Plutonium
2
2
  module Resource
3
3
  class Definition < Plutonium::Definition::Base
4
+ class_attribute :modal_mode, default: :slideover, instance_accessor: false
5
+
6
+ VALID_MODAL_MODES = [:centered, :slideover, false].freeze
7
+
8
+ # Sets how :new / :edit actions render.
9
+ # - :slideover (default) — slide-in panel from the right
10
+ # - :centered — centered dialog
11
+ # - false — no modal; new/edit are full standalone pages
12
+ def self.modal(mode)
13
+ unless VALID_MODAL_MODES.include?(mode)
14
+ raise ArgumentError, "modal must be one of #{VALID_MODAL_MODES.inspect}, got #{mode.inspect}"
15
+ end
16
+ self.modal_mode = mode
17
+ configure_crud_modal_targets!
18
+ end
19
+
20
+ # Re-derives the default :new / :edit actions so their turbo_frame
21
+ # matches the current `modal_mode`. Called when `.modal` is set
22
+ # and once at Resource::Definition load (so the default
23
+ # :slideover state propagates to the action records). Subclasses
24
+ # inherit those records via DefineableProps#inherited (deep_dup);
25
+ # calling `.modal` on a subclass re-runs this method locally.
26
+ def self.configure_crud_modal_targets!
27
+ target = (modal_mode == false) ? nil : Plutonium::REMOTE_MODAL_FRAME
28
+ [:new, :edit].each do |name|
29
+ action = defined_actions[name]
30
+ next unless action
31
+ next if action.turbo_frame == target
32
+ defined_actions[name] = action.with(turbo_frame: target)
33
+ end
34
+ end
35
+
36
+ def modal
37
+ self.class.modal_mode
38
+ end
39
+
40
+ # Apply the default modal target ("remote_modal") to :new / :edit
41
+ # so resources that never call `.modal` still get the slideover
42
+ # behavior. Subclasses inherit the configured actions via
43
+ # DefineableProps' deep_dup; calling `.modal` on a subclass
44
+ # re-runs the configuration locally.
45
+ configure_crud_modal_targets!
4
46
  end
5
47
  end
6
48
  end
@@ -67,16 +67,31 @@ module Plutonium
67
67
  raise ArgumentError, "parent and parent_association must both be provided together"
68
68
  end
69
69
 
70
- # Parent association scoping (nested routes)
71
- # The parent was already entity-scoped during authorization, so children
72
- # accessed through the parent don't need additional entity scoping
70
+ # Parent association scoping (nested routes).
71
+ #
72
+ # The parent context is set on the policy for the whole request, so it
73
+ # leaks into sibling lookups too — e.g. a SecureAssociation field on
74
+ # the child's form authorizes an unrelated resource scope while
75
+ # parent/parent_association are still set. Only apply parent scoping
76
+ # when the relation actually corresponds to the parent's named
77
+ # association; otherwise fall through to entity scoping so we don't
78
+ # produce an incoherent (and silently empty) result.
73
79
  assoc_reflection = parent.class.reflect_on_association(parent_association)
74
- if assoc_reflection.collection?
75
- # has_many: merge with the association's scope
76
- parent.public_send(parent_association).merge(relation)
80
+ if assoc_reflection && relation.klass <= assoc_reflection.klass
81
+ # The parent was already entity-scoped during authorization, so
82
+ # children accessed through the parent don't need additional
83
+ # entity scoping.
84
+ if assoc_reflection.collection?
85
+ # has_many: merge with the association's scope
86
+ parent.public_send(parent_association).merge(relation)
87
+ else
88
+ # has_one: scope by foreign key
89
+ relation.where(assoc_reflection.foreign_key => parent.id)
90
+ end
91
+ elsif entity_scope
92
+ relation.associated_with(entity_scope)
77
93
  else
78
- # has_one: scope by foreign key
79
- relation.where(assoc_reflection.foreign_key => parent.id)
94
+ relation
80
95
  end
81
96
  elsif entity_scope
82
97
  # Entity scoping (multi-tenancy)
@@ -63,6 +63,7 @@ module Plutonium
63
63
  # Builds a URL with the given options for search and sorting.
64
64
  #
65
65
  # @param options [Hash] The options for building the URL.
66
+ # @option options [Boolean] :replace When true, clears all existing sorts before applying the new one
66
67
  # @return [String] The constructed URL with query parameters.
67
68
  def build_url(**options)
68
69
  q = {}
@@ -74,11 +75,19 @@ module Plutonium
74
75
  selected_scope_filter
75
76
  end
76
77
 
77
- q[:sort_directions] = selected_sort_directions.dup
78
- q[:sort_fields] = selected_sort_fields.dup
78
+ if options.delete(:replace)
79
+ q[:sort_directions] = {}
80
+ q[:sort_fields] = []
81
+ else
82
+ q[:sort_directions] = selected_sort_directions.dup
83
+ q[:sort_fields] = selected_sort_fields.dup
84
+ end
79
85
  handle_sort_options!(q, options)
80
86
 
81
- q.merge! params.slice(*filter_definitions.keys)
87
+ filter_keys = filter_definitions.keys.map(&:to_sym)
88
+ filter_overrides = options.slice(*filter_keys).stringify_keys
89
+ q.merge! params.with_indifferent_access.slice(*filter_definitions.keys)
90
+ q.merge!(filter_overrides)
82
91
  compacted = deep_compact({q: q})
83
92
 
84
93
  # Preserve explicit "All" selection (scope: nil in options means show all)
@@ -119,18 +128,67 @@ module Plutonium
119
128
 
120
129
  def sort_definitions = @sort_definitions ||= {}.with_indifferent_access
121
130
 
131
+ # Returns an array of hashes describing each currently active filter.
132
+ # Each hash has: name, label, value_label, clear_url
133
+ def active_filter_descriptions
134
+ filter_definitions.filter_map do |name, filter|
135
+ name = name.to_sym
136
+ filter_params = params[name]
137
+ next unless filter_params.present?
138
+
139
+ value_label = case filter_params
140
+ when Hash, ActionController::Parameters
141
+ entries = filter_params.to_h.reject { |_, v| v.blank? }
142
+ next if entries.empty?
143
+ # Single-input filters defer to the filter's `humanize_value`
144
+ # (e.g. Association resolves ids to labels, Boolean translates
145
+ # "true" -> "Yes"). Multi-input filters keep input-name
146
+ # qualifiers (e.g. "from 2024, to 2025").
147
+ if entries.size == 1
148
+ humanized = filter.humanize_value(entries.values.first)
149
+ next if humanized.blank?
150
+ humanized
151
+ else
152
+ entries.map { |k, v| "#{k.to_s.humanize.downcase} #{v}" }.join(", ")
153
+ end
154
+ when Array
155
+ entries = filter_params.reject(&:blank?)
156
+ next if entries.empty?
157
+ humanized = filter.humanize_value(entries)
158
+ next if humanized.blank?
159
+ humanized
160
+ else
161
+ next if filter_params.to_s.blank?
162
+ humanized = filter.humanize_value(filter_params)
163
+ next if humanized.blank?
164
+ humanized
165
+ end
166
+
167
+ {
168
+ name: name,
169
+ label: name.to_s.humanize,
170
+ value_label: value_label,
171
+ clear_url: build_url(name => nil)
172
+ }
173
+ end
174
+ end
175
+
122
176
  # Provides sorting parameters for the given field name.
123
177
  #
124
178
  # @param name [Symbol, String] The name of the field to sort.
125
- # @return [Hash, nil] The sorting parameters including URL and direction.
179
+ # @return [Hash, nil] The sorting parameters including URL, multi_url, direction, position and multi flag.
126
180
  def sort_params_for(name)
127
181
  return unless sort_definitions[name]
128
182
 
183
+ multi = selected_sort_fields.size > 1 && selected_sort_fields.include?(name.to_s)
184
+
129
185
  {
130
- url: build_url(sort: name),
186
+ url: build_url(sort: name, replace: true),
187
+ multi_url: build_url(sort: name),
131
188
  reset_url: build_url(sort: name, reset: true),
132
189
  position: selected_sort_fields.index(name.to_s),
133
- direction: selected_sort_directions[name]
190
+ direction: selected_sort_directions[name],
191
+ multi: multi
134
192
  }
135
193
  end
136
194
 
@@ -33,8 +33,8 @@ module Plutonium
33
33
  klass.defined_fields.each_key do |field_name|
34
34
  next if field_name == :id
35
35
  assert resource_class.column_names.include?(field_name.to_s) ||
36
- resource_class.method_defined?(field_name) ||
37
- resource_class.reflect_on_association(field_name),
36
+ resource_class.method_defined?(field_name) ||
37
+ resource_class.reflect_on_association(field_name),
38
38
  "Field :#{field_name} declared in #{klass} but not defined on #{resource_class}"
39
39
  end
40
40
  end
@@ -24,10 +24,11 @@ module Plutonium
24
24
 
25
25
  DROPDOWN_DEFAULT_COLOR = "text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)]"
26
26
 
27
- def initialize(action, url:, variant: :default)
27
+ def initialize(action, url:, variant: :default, data: {})
28
28
  @action = action
29
29
  @url = url
30
30
  @variant = variant
31
+ @extra_data = data
31
32
  end
32
33
 
33
34
  def view_template
@@ -49,7 +50,7 @@ module Plutonium
49
50
  link_to(
50
51
  url_with_return_to,
51
52
  class: button_classes,
52
- data: {turbo_frame: @action.turbo_frame}
53
+ data: {turbo_frame: @action.turbo_frame}.merge(@extra_data)
53
54
  ) do
54
55
  render_button_content
55
56
  end
@@ -61,6 +62,7 @@ module Plutonium
61
62
  method: @action.route_options.method,
62
63
  name: :return_to, value: return_to_url,
63
64
  class: "inline-block",
65
+ data: @extra_data,
64
66
  form: {
65
67
  data: {
66
68
  turbo: @action.turbo,
@@ -78,6 +78,8 @@ module Plutonium
78
78
 
79
79
  def BuildTableScopesBar(...) = Plutonium::UI::Table::Components::ScopesBar.new(...)
80
80
 
81
+ def BuildTableScopesPills(...) = Plutonium::UI::Table::Components::ScopesPills.new(...)
82
+
81
83
  def BuildTableInfo(...) = Plutonium::UI::Table::Components::PagyInfo.new(...)
82
84
 
83
85
  def BuildTablePagination(...) = Plutonium::UI::Table::Components::PagyPagination.new(...)
@@ -86,7 +88,17 @@ module Plutonium
86
88
 
87
89
  def BuildBulkActionsToolbar(...) = Plutonium::UI::Table::Components::BulkActionsToolbar.new(...)
88
90
 
91
+ def BuildTableToolbar(...) = Plutonium::UI::Table::Components::Toolbar.new(...)
92
+
93
+ def BuildTableFilterPills(...) = Plutonium::UI::Table::Components::FilterPills.new(...)
94
+
95
+ def BuildTableViewSwitcher(...) = Plutonium::UI::Table::Components::ViewSwitcher.new(...)
96
+
89
97
  def BuildColorModeSelector(...) = Plutonium::UI::ColorModeSelector.new(...)
98
+
99
+ def BuildModalCentered(...) = Plutonium::UI::Modal::Centered.new(...)
100
+
101
+ def BuildModalSlideover(...) = Plutonium::UI::Modal::Slideover.new(...)
90
102
  end
91
103
  end
92
104
  end
@@ -46,7 +46,9 @@ module Plutonium
46
46
 
47
47
  def fields_wrapper(&)
48
48
  div(class: themed(:fields_wrapper)) {
49
- yield
49
+ div(class: themed(:fields_inner)) {
50
+ yield
51
+ }
50
52
  }
51
53
  end
52
54
  end
@@ -13,56 +13,93 @@ module Plutonium
13
13
  @resource_definition = resource_definition
14
14
  end
15
15
 
16
+ # Metadata fields the user is permitted to see — intersection of
17
+ # the definition's declared metadata with the policy-filtered
18
+ # `resource_fields`. Computed lazily so we don't run the
19
+ # intersection when the resource doesn't declare any metadata.
20
+ def metadata_fields
21
+ @metadata_fields ||= resource_definition.defined_metadata_fields & resource_fields
22
+ end
23
+
16
24
  def display_template
17
- render_fields
18
- render_associations if present_associations?
25
+ if associations_present?
26
+ render_tablist_with_details
27
+ else
28
+ render_fields
29
+ end
19
30
  end
20
31
 
21
32
  private
22
33
 
34
+ def associations_present?
35
+ present_associations? && resource_associations.present?
36
+ end
37
+
23
38
  def render_fields
39
+ if metadata_fields.any?
40
+ div(class: "grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_320px] gap-6 items-start") do
41
+ div { render_main_field_card }
42
+ aside { render_metadata_panel }
43
+ end
44
+ else
45
+ render_main_field_card
46
+ end
47
+ end
48
+
49
+ def render_main_field_card
24
50
  Block do
25
51
  fields_wrapper do
26
- resource_fields.each do |name|
52
+ # Skip fields claimed by the metadata panel — rendering
53
+ # them in both places duplicates information.
54
+ (resource_fields - metadata_fields).each do |name|
27
55
  render_resource_field name
28
56
  end
29
57
  end
30
58
  end
31
59
  end
32
60
 
33
- def render_associations
34
- return unless resource_associations.present?
61
+ # Renders the declared metadata fields as a vertical stack beside
62
+ # the main field card. Reuses render_resource_field (same path
63
+ # the main details use) so labels/values match in style; the
64
+ # only difference from the main card is the wrapper — a single
65
+ # column of fields instead of the form's multi-column grid.
66
+ def render_metadata_panel
67
+ Block do
68
+ div(class: "pu-card-body flex flex-col gap-6") do
69
+ metadata_fields.each { |name| render_resource_field(name) }
70
+ end
71
+ end
72
+ end
35
73
 
74
+ def render_tablist_with_details
36
75
  tablist = BuildTabList()
37
76
 
77
+ # Build an inner display component for the Details tab.
78
+ # It must be a standalone Phlex component so that TabList can call
79
+ # `render(details_display)` from within its own context. Phlex propagates
80
+ # @_state through render calls, so the inner component writes to the same
81
+ # buffer as the outer Resource display even though self changes.
82
+ details_display = build_details_display
83
+
84
+ tablist.with_tab(
85
+ identifier: "details",
86
+ title: -> { plain "Details" }
87
+ ) do
88
+ render details_display
89
+ end
90
+
38
91
  resource_associations.each do |name|
39
92
  reflection = object.class.reflect_on_association name
40
-
41
- if !reflection
42
- raise ArgumentError,
43
- "unknown association #{object.class}##{name} defined in #permitted_associations"
44
- elsif !registered_resources.include?(reflection.klass)
45
- raise ArgumentError,
46
- "#{object.class}##{name} defined in #permitted_associations, but #{reflection.klass} is not a registered resource"
47
- end
93
+ raise_unknown_association(name) unless reflection
94
+ raise_unregistered_association(name, reflection) unless registered_resources.include?(reflection.klass)
48
95
 
49
96
  title = object.class.human_attribute_name(name)
50
- src = case reflection.macro
51
- when :belongs_to
52
- associated = object.public_send name
53
- resource_url_for(associated, parent: nil) if associated
54
- when :has_one
55
- associated = object.public_send name
56
- resource_url_for(associated, parent: object, association: name)
57
- when :has_many
58
- resource_url_for(reflection.klass, parent: object, association: name)
59
- end
60
-
97
+ src = association_src(name, reflection)
61
98
  next unless src
62
99
 
63
100
  tablist.with_tab(
64
101
  identifier: title.parameterize,
65
- title: -> { h5(class: "text-2xl font-bold tracking-tight text-[var(--pu-text)]") { title } }
102
+ title: -> { plain title }
66
103
  ) do
67
104
  FrameNavigatorPanel(title: "", src:, panel_id: "association-panel-#{title.parameterize}")
68
105
  end
@@ -71,6 +108,53 @@ module Plutonium
71
108
  render tablist
72
109
  end
73
110
 
111
+ # Builds a standalone Phlex component whose sole job is to render the
112
+ # resource fields. Having a distinct component lets TabList call
113
+ # `render(details_display)` so that Phlex propagates its @_state correctly,
114
+ # while avoiding the `instance_exec` context-switch problem that would
115
+ # occur if we put `render_fields` directly inside the `with_tab` block.
116
+ #
117
+ # The anonymous subclass overrides `view_template` to skip the outer
118
+ # `display_wrapper` div (which would duplicate the dom id already emitted
119
+ # by the parent Resource display) and renders just the fields content.
120
+ def build_details_display
121
+ resource = self
122
+
123
+ klass = Class.new(self.class) do
124
+ define_method(:view_template) do
125
+ resource.send(:render_fields)
126
+ end
127
+ end
128
+
129
+ klass.new(
130
+ object,
131
+ resource_fields: resource_fields,
132
+ resource_associations: [],
133
+ resource_definition: resource_definition
134
+ )
135
+ end
136
+
137
+ def association_src(name, reflection)
138
+ case reflection.macro
139
+ when :belongs_to
140
+ associated = object.public_send name
141
+ resource_url_for(associated, parent: nil) if associated
142
+ when :has_one
143
+ associated = object.public_send name
144
+ resource_url_for(associated, parent: object, association: name)
145
+ when :has_many
146
+ resource_url_for(reflection.klass, parent: object, association: name)
147
+ end
148
+ end
149
+
150
+ def raise_unknown_association(name)
151
+ raise ArgumentError, "unknown association #{object.class}##{name} defined in #permitted_associations"
152
+ end
153
+
154
+ def raise_unregistered_association(name, reflection)
155
+ raise ArgumentError, "#{object.class}##{name} defined in #permitted_associations, but #{reflection.klass} is not a registered resource"
156
+ end
157
+
74
158
  def render_resource_field(name)
75
159
  when_permitted(name) do
76
160
  # field :name, as: :string
@@ -8,7 +8,8 @@ module Plutonium
8
8
  super.merge({
9
9
  base: "",
10
10
  value_wrapper: "max-h-[300px] overflow-y-auto",
11
- fields_wrapper: "p-8 grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-x-8 gap-y-8 grid-flow-row-dense",
11
+ fields_wrapper: "pu-card",
12
+ fields_inner: "pu-card-body grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-x-8 gap-y-6 grid-flow-row-dense",
12
13
 
13
14
  # Labels and descriptions
14
15
  label: "text-sm font-semibold uppercase tracking-wide text-[var(--pu-text-muted)] mb-2",
@@ -1,29 +1,23 @@
1
1
  module Plutonium
2
2
  module UI
3
3
  module DynaFrame
4
+ # Conditionally wraps its content in a turbo-frame matching the inbound
5
+ # request's `Turbo-Frame` header. In frame mode adds the flash partial
6
+ # so toast/alert messages still surface inside frames; in non-frame
7
+ # mode renders the block as-is.
4
8
  class Content < Plutonium::UI::Component::Base
5
9
  include Phlex::Rails::Helpers::TurboFrameTag
6
10
 
7
- def initialize(content = nil)
8
- @content = content
9
- end
10
-
11
- def view_template
11
+ def view_template(&block)
12
12
  if current_turbo_frame.present?
13
- # Frame request: render only the turbo-frame with content
14
13
  turbo_frame_tag(current_turbo_frame) do
15
14
  render partial("flash")
16
- @content&.call
15
+ yield if block_given?
17
16
  end
18
- else
19
- # Regular request: yield self so caller can call frame.render_content
20
- yield(self)
17
+ elsif block_given?
18
+ yield
21
19
  end
22
20
  end
23
-
24
- def render_content
25
- @content&.call
26
- end
27
21
  end
28
22
  end
29
23
  end
@@ -8,7 +8,7 @@ module Plutonium
8
8
  end
9
9
 
10
10
  def view_template
11
- div(class: "pu-card") do
11
+ div(class: "pu-card mt-4") do
12
12
  div(class: "pu-empty-state") do
13
13
  p(class: "pu-empty-state-description") { message }
14
14
  yield if block_given?
@@ -10,6 +10,28 @@ module Plutonium
10
10
  include Phlexi::Field::Common::Tokens
11
11
  include Plutonium::UI::Form::Options::InferredTypes
12
12
 
13
+ # Consume `:as` here so it doesn't land in Phlexi's `@options` —
14
+ # `:as` is a Plutonium-internal concept (it picks the tag method),
15
+ # not a Phlexi field option.
16
+ def initialize(*args, as: nil, **kwargs, &block)
17
+ @as = as
18
+ super(*args, **kwargs, &block)
19
+ end
20
+
21
+ attr_reader :as
22
+
23
+ def hidden?
24
+ as.to_s == "hidden"
25
+ end
26
+
27
+ # Hidden fields (`form.field(name, as: :hidden)`) skip the label /
28
+ # hint / error chrome and render inside a `<div hidden>` so they're
29
+ # also excluded from CSS Grid / Flex layout.
30
+ def wrapped(**, &)
31
+ return Plutonium::UI::Form::Components::HiddenWrapper.new(self, &) if hidden?
32
+ super
33
+ end
34
+
13
35
  def textarea_tag(**attributes, &)
14
36
  attributes[:data_controller] = tokens(attributes[:data_controller], "textarea-autogrow")
15
37
  super
@@ -45,7 +67,13 @@ module Plutonium
45
67
  end
46
68
 
47
69
  def resource_select_tag(**attributes, &)
48
- create_component(Components::ResourceSelect, :select, **attributes, &)
70
+ attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select")
71
+ # class!: "" clears the underlying <select>'s themed classes
72
+ # (pu-input etc.) — the visible element is slim-select's
73
+ # generated .ss-main, so leaving Tailwind input chrome on the
74
+ # native select can leak into chip layout (e.g. forcing
75
+ # flex-direction: column or w-full on multi-mode chips).
76
+ create_component(Components::ResourceSelect, :select, class!: "", **attributes, &)
49
77
  end
50
78
 
51
79
  def secure_association_tag(**attributes, &)