plutonium 0.50.0 → 0.52.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 (201) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +574 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +167 -302
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +674 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +9 -6
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +44 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1010 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +38 -29
  18. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
  19. data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
  20. data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
  21. data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
  22. data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
  23. data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
  24. data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
  25. data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
  26. data/docs/.vitepress/theme/custom.css +144 -0
  27. data/docs/.vitepress/theme/index.ts +58 -1
  28. data/docs/getting-started/index.md +33 -57
  29. data/docs/getting-started/installation.md +37 -80
  30. data/docs/getting-started/tutorial/02-first-resource.md +17 -8
  31. data/docs/getting-started/tutorial/03-authentication.md +31 -23
  32. data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
  33. data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
  34. data/docs/getting-started/tutorial/07-author-portal.md +8 -0
  35. data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
  36. data/docs/getting-started/tutorial/index.md +4 -5
  37. data/docs/guides/adding-resources.md +66 -377
  38. data/docs/guides/authentication.md +98 -462
  39. data/docs/guides/authorization.md +124 -370
  40. data/docs/guides/creating-packages.md +93 -298
  41. data/docs/guides/custom-actions.md +126 -441
  42. data/docs/guides/customizing-ui.md +258 -0
  43. data/docs/guides/index.md +49 -52
  44. data/docs/guides/multi-tenancy.md +123 -186
  45. data/docs/guides/nested-resources.md +137 -396
  46. data/docs/guides/search-filtering.md +127 -238
  47. data/docs/guides/testing.md +10 -5
  48. data/docs/guides/theming.md +168 -405
  49. data/docs/guides/troubleshooting.md +5 -3
  50. data/docs/guides/user-invites.md +112 -425
  51. data/docs/guides/user-profile.md +82 -241
  52. data/docs/index.md +10 -219
  53. data/docs/public/asciinema/home-scaffold.cast +305 -0
  54. data/docs/public/images/guides/custom-actions-bulk.png +0 -0
  55. data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
  56. data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
  57. data/docs/public/images/guides/nested-inputs.png +0 -0
  58. data/docs/public/images/guides/nested-resources-tab.png +0 -0
  59. data/docs/public/images/guides/search-filtering-index.png +0 -0
  60. data/docs/public/images/guides/search-filtering-panel.png +0 -0
  61. data/docs/public/images/guides/theming-after.png +0 -0
  62. data/docs/public/images/guides/theming-before.png +0 -0
  63. data/docs/public/images/guides/user-invites-landing.png +0 -0
  64. data/docs/public/images/guides/user-profile-edit.png +0 -0
  65. data/docs/public/images/guides/user-profile-show.png +0 -0
  66. data/docs/public/images/home-index.png +0 -0
  67. data/docs/public/images/home-new.png +0 -0
  68. data/docs/public/images/home-show.png +0 -0
  69. data/docs/public/images/tutorial/02-empty-index.png +0 -0
  70. data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
  71. data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
  72. data/docs/public/images/tutorial/02-new-form.png +0 -0
  73. data/docs/public/images/tutorial/03-create-account.png +0 -0
  74. data/docs/public/images/tutorial/03-login.png +0 -0
  75. data/docs/public/images/tutorial/04-admin-index.png +0 -0
  76. data/docs/public/images/tutorial/05-actions-menu.png +0 -0
  77. data/docs/public/images/tutorial/05-row-actions.png +0 -0
  78. data/docs/public/images/tutorial/06-comments-tab.png +0 -0
  79. data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
  80. data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
  81. data/docs/public/images/tutorial/07-author-portal.png +0 -0
  82. data/docs/public/images/tutorial/08-customized-index.png +0 -0
  83. data/docs/reference/app/generators.md +517 -0
  84. data/docs/reference/app/index.md +158 -0
  85. data/docs/reference/app/packages.md +146 -0
  86. data/docs/reference/app/portals.md +377 -0
  87. data/docs/reference/auth/accounts.md +229 -0
  88. data/docs/reference/auth/index.md +88 -0
  89. data/docs/reference/auth/profile.md +185 -0
  90. data/docs/reference/behavior/controllers.md +395 -0
  91. data/docs/reference/behavior/index.md +22 -0
  92. data/docs/reference/behavior/interactions.md +341 -0
  93. data/docs/reference/behavior/policies.md +417 -0
  94. data/docs/reference/index.md +67 -48
  95. data/docs/reference/resource/actions.md +423 -0
  96. data/docs/reference/resource/definition.md +508 -0
  97. data/docs/reference/resource/index.md +50 -0
  98. data/docs/reference/resource/model.md +348 -0
  99. data/docs/reference/resource/query.md +305 -0
  100. data/docs/reference/tenancy/entity-scoping.md +368 -0
  101. data/docs/reference/tenancy/index.md +36 -0
  102. data/docs/reference/tenancy/invites.md +400 -0
  103. data/docs/reference/tenancy/nested-resources.md +267 -0
  104. data/docs/reference/testing/index.md +287 -0
  105. data/docs/reference/ui/assets.md +400 -0
  106. data/docs/reference/ui/components.md +165 -0
  107. data/docs/reference/ui/displays.md +104 -0
  108. data/docs/reference/ui/forms.md +284 -0
  109. data/docs/reference/ui/index.md +30 -0
  110. data/docs/reference/ui/layouts.md +106 -0
  111. data/docs/reference/ui/pages.md +189 -0
  112. data/docs/reference/ui/tables.md +121 -0
  113. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
  114. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -0
  115. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  116. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  117. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  118. data/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -0
  119. data/gemfiles/rails_7.gemfile.lock +1 -1
  120. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  121. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  122. data/lib/generators/pu/core/assets/assets_generator.rb +10 -0
  123. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  124. data/lib/generators/pu/invites/install_generator.rb +45 -0
  125. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
  126. data/lib/generators/pu/profile/conn_generator.rb +2 -2
  127. data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
  128. data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
  129. data/lib/generators/pu/rodauth/account_generator.rb +2 -1
  130. data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
  131. data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
  132. data/lib/generators/pu/rodauth/views_generator.rb +0 -2
  133. data/lib/generators/pu/saas/membership/USAGE +4 -1
  134. data/lib/generators/pu/saas/setup_generator.rb +16 -4
  135. data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
  136. data/lib/plutonium/definition/base.rb +1 -1
  137. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  138. data/lib/plutonium/helpers/turbo_helper.rb +30 -0
  139. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  140. data/lib/plutonium/resource/controller.rb +1 -0
  141. data/lib/plutonium/resource/controllers/crud_actions.rb +23 -5
  142. data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
  143. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  144. data/lib/plutonium/resource/policy.rb +7 -0
  145. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  146. data/lib/plutonium/ui/component/methods.rb +5 -0
  147. data/lib/plutonium/ui/form/base.rb +23 -3
  148. data/lib/plutonium/ui/form/components/json.rb +58 -0
  149. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  150. data/lib/plutonium/ui/form/components/secure_association.rb +103 -22
  151. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  152. data/lib/plutonium/ui/form/interaction.rb +1 -1
  153. data/lib/plutonium/ui/form/resource.rb +0 -4
  154. data/lib/plutonium/ui/form/theme.rb +1 -1
  155. data/lib/plutonium/ui/grid/resource.rb +1 -1
  156. data/lib/plutonium/ui/layout/base.rb +1 -0
  157. data/lib/plutonium/ui/page/base.rb +0 -7
  158. data/lib/plutonium/ui/page/edit.rb +1 -1
  159. data/lib/plutonium/ui/page/index.rb +4 -4
  160. data/lib/plutonium/ui/page/new.rb +1 -1
  161. data/lib/plutonium/ui/table/components/filter_form.rb +12 -4
  162. data/lib/plutonium/ui/table/resource.rb +1 -1
  163. data/lib/plutonium/version.rb +1 -1
  164. data/lib/plutonium.rb +8 -0
  165. data/lib/tasks/release.rake +15 -1
  166. data/package.json +13 -10
  167. data/src/css/slim_select.css +4 -0
  168. data/src/js/controllers/form_controller.js +5 -4
  169. data/src/js/controllers/slim_select_controller.js +61 -0
  170. data/src/js/turbo/turbo_actions.js +33 -0
  171. data/yarn.lock +661 -544
  172. metadata +86 -33
  173. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  174. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  175. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  176. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  177. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  178. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  179. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  180. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  181. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  182. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  183. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  184. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  185. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  186. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  187. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  188. data/docs/reference/assets/index.md +0 -496
  189. data/docs/reference/controller/index.md +0 -412
  190. data/docs/reference/definition/actions.md +0 -462
  191. data/docs/reference/definition/fields.md +0 -383
  192. data/docs/reference/definition/index.md +0 -326
  193. data/docs/reference/definition/query.md +0 -351
  194. data/docs/reference/generators/index.md +0 -648
  195. data/docs/reference/interaction/index.md +0 -449
  196. data/docs/reference/model/features.md +0 -248
  197. data/docs/reference/model/index.md +0 -218
  198. data/docs/reference/policy/index.md +0 -456
  199. data/docs/reference/portal/index.md +0 -379
  200. data/docs/reference/views/forms.md +0 -411
  201. data/docs/reference/views/index.md +0 -544
@@ -7,6 +7,7 @@ module Plutonium
7
7
  # Select for choosing a resource record
8
8
  class ResourceSelect < Phlexi::Form::Components::Select
9
9
  include Plutonium::UI::Component::Methods
10
+ include Plutonium::UI::Form::Concerns::TypeaheadAttributes
10
11
 
11
12
  # Cap on the number of records the dropdown materialises. Keeps
12
13
  # very large association tables from rendering thousands of
@@ -23,18 +24,23 @@ module Plutonium
23
24
  elsif @association_class.nil?
24
25
  []
25
26
  else
26
- relation = @association_class.all
27
- relation = relation.limit(@choice_limit) if relation.respond_to?(:limit) && @choice_limit
28
- if @skip_authorization
29
- relation
30
- else
31
- authorized_resource_scope(@association_class, relation: relation)
32
- end
27
+ authorized_relation(limit: @choice_limit)
33
28
  end
34
29
  build_choice_mapper(collection)
35
30
  end
36
31
  end
37
32
 
33
+ # Builds the authorized relation for the association class, optionally
34
+ # capped at `limit`. Shared by `choices` (with the limit) and
35
+ # `normalize_simple_input` (without — so typeahead picks beyond the
36
+ # rendered subset still validate).
37
+ def authorized_relation(limit: nil)
38
+ relation = @association_class.all
39
+ relation = relation.limit(limit) if limit && relation.respond_to?(:limit)
40
+ return relation if @skip_authorization
41
+ authorized_resource_scope(@association_class, relation: relation)
42
+ end
43
+
38
44
  def build_attributes
39
45
  # Defaults must land BEFORE super — AcceptsChoices.build_attributes
40
46
  # consumes :value_method / :label_method off `attributes` into
@@ -48,6 +54,17 @@ module Plutonium
48
54
  @skip_authorization = attributes.delete(:skip_authorization)
49
55
  @choice_limit = attributes.fetch(:choice_limit) { DEFAULT_CHOICE_LIMIT }
50
56
  attributes.delete(:choice_limit)
57
+ # Stash the typeahead option; the URL helper needs view_context
58
+ # which only exists once we're rendering.
59
+ @typeahead_option = attributes.delete(:typeahead)
60
+ end
61
+
62
+ # Phlex hook fires right before view_template runs and view_context
63
+ # is available, so this is where we can resolve the typeahead URL
64
+ # and inject the data attr.
65
+ def before_template
66
+ super
67
+ configure_typeahead_attributes!(@typeahead_option)
51
68
  end
52
69
 
53
70
  # SGIDs include a timestamp + signature, so the SGID in the URL
@@ -69,9 +86,22 @@ module Plutonium
69
86
  # string-equals a freshly generated option SGID for the same
70
87
  # record, so the value gets silently dropped — no WHERE clause
71
88
  # is built and the filter behaves as if it weren't applied.
72
- # Match by decoded model id so the input survives.
89
+ #
90
+ # For SGID values backed by an association class, validate against
91
+ # the unbounded authorized relation rather than the rendered
92
+ # `choices` (which is capped at `choice_limit` and may not include
93
+ # records reachable via typeahead). For raw values / explicit
94
+ # `@raw_choices`, fall back to record-equality against the rendered
95
+ # options.
73
96
  def normalize_simple_input(input_value)
74
97
  return nil if input_value.blank?
98
+
99
+ sgid = SignedGlobalID.parse(input_value)
100
+ if sgid && @association_class && !@raw_choices
101
+ return nil unless sgid.model_class <= @association_class
102
+ return authorized_relation.exists?(id: sgid.model_id) ? input_value : nil
103
+ end
104
+
75
105
  choices.values.find { |opt| same_record?(input_value, opt) } && input_value
76
106
  end
77
107
 
@@ -102,6 +132,30 @@ module Plutonium
102
132
  def blank_option_text
103
133
  @include_blank.is_a?(String) ? @include_blank : super
104
134
  end
135
+
136
+ private
137
+
138
+ def typeahead_target_class
139
+ @association_class
140
+ end
141
+
142
+ def typeahead_kind_and_name(_typeahead_option)
143
+ detect_typeahead_kind_and_name
144
+ end
145
+
146
+ # Plutonium::UI::Form::Query roots its form with `as: :q`, so
147
+ # any field whose ancestry includes a node keyed :q is a filter
148
+ # input. The filter name is the immediate child of that root.
149
+ # Form inputs (new/edit) fall through to :input + the field key.
150
+ def detect_typeahead_kind_and_name
151
+ lineage = field.dom.lineage
152
+ q_index = lineage.find_index { |node| node.key == :q }
153
+ if q_index && (filter_node = lineage[q_index + 1])
154
+ [:filter, filter_node.key]
155
+ else
156
+ [:input, field.key]
157
+ end
158
+ end
105
159
  end
106
160
  end
107
161
  end
@@ -6,6 +6,7 @@ module Plutonium
6
6
  module Components
7
7
  class SecureAssociation < Phlexi::Form::Components::AssociationBase
8
8
  include Plutonium::UI::Component::Methods
9
+ include Plutonium::UI::Form::Concerns::TypeaheadAttributes
9
10
 
10
11
  DEFAULT_CHOICE_LIMIT = Plutonium::UI::Form::Components::ResourceSelect::DEFAULT_CHOICE_LIMIT
11
12
 
@@ -21,46 +22,100 @@ module Plutonium
21
22
  delegate :association_reflection, to: :field
22
23
 
23
24
  def render_add_button
24
- return if @add_action == false || add_url.nil?
25
+ return if @add_action == false
26
+
27
+ # Two stacking levels are supported (primary + secondary modal).
28
+ # Hide the "+" entirely once we're already inside the secondary —
29
+ # there's no tertiary frame to escalate to.
30
+ return if in_secondary_modal?
31
+
32
+ url, turbo_frame = add_url_and_frame
33
+ return unless url
34
+
35
+ # When the parent form is already inside the primary modal,
36
+ # route the "+" to the secondary frame so the stacked dialog
37
+ # opens on top of the original form rather than replacing it.
38
+ # The crud controller mirrors this on success — closing the
39
+ # secondary modal and reloading the primary so the association
40
+ # select picks up the new record.
41
+ if turbo_frame == Plutonium::REMOTE_MODAL_FRAME && in_modal?
42
+ turbo_frame = Plutonium::REMOTE_MODAL_SECONDARY_FRAME
43
+ end
25
44
 
26
- a(
27
- href: add_url,
45
+ attrs = {
46
+ href: url,
28
47
  class: "inline-flex items-center justify-center w-9 h-9 shrink-0 bg-[var(--pu-surface-alt)] hover:bg-[var(--pu-border)] border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] focus:ring-2 focus:ring-[var(--pu-border)] focus:outline-none text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] transition-colors"
29
- ) do
48
+ }
49
+ attrs[:data] = {turbo_frame: turbo_frame} if turbo_frame
50
+
51
+ a(**attrs) do
30
52
  render Phlex::TablerIcons::Plus.new(class: "w-4 h-4")
31
53
  end
32
54
  end
33
55
 
34
- def add_url
35
- @add_url ||= begin
36
- return unless @skip_authorization || allowed_to?(:create?, association_reflection.klass)
56
+ # Resolves the destination for the inline "+" button alongside
57
+ # the association select. We go through the target resource's
58
+ # `:new` action (rather than building a URL by hand) so the
59
+ # button inherits whatever modal/slideover frame the target
60
+ # resource is configured for — same path table/grid use for
61
+ # their own "New" button. A custom string `add_action:` skips
62
+ # the frame lookup since we can't infer the target's modal
63
+ # mode from an arbitrary URL.
64
+ def add_url_and_frame
65
+ klass = association_reflection.klass
66
+
67
+ if @add_action.is_a?(String)
68
+ return [with_return_to(@add_action), nil] if @skip_authorization || allowed_to?(:create?, klass)
69
+ return
70
+ end
37
71
 
38
- url = @add_action || (registered_resources.include?(association_reflection.klass) && resource_url_for(association_reflection.klass, action: :new, parent: nil))
39
- return unless url
72
+ return unless registered_resources.include?(klass)
73
+ action = resource_definition(klass).defined_actions[:new]
74
+ return unless action
75
+ return unless @skip_authorization || action.permitted_by?(policy_for(record: klass))
40
76
 
41
- uri = URI(url)
42
- uri.query = URI.encode_www_form({return_to: request.original_url})
43
- uri.to_s
44
- end
77
+ url = route_options_to_url(action.route_options, klass)
78
+ [with_return_to(url), action.turbo_frame]
79
+ end
80
+
81
+ def with_return_to(url)
82
+ uri = URI(url)
83
+ params = Rack::Utils.parse_nested_query(uri.query)
84
+ params["return_to"] = request.original_url
85
+ uri.query = params.to_query
86
+ uri.to_s
45
87
  end
46
88
 
47
89
  def choices
48
90
  @choices ||= begin
49
- collection = if @raw_choices
50
- @raw_choices
51
- elsif @skip_authorization
52
- choices_from_association(association_reflection.klass)
53
- else
54
- authorized_resource_scope(association_reflection.klass, relation: choices_from_association(association_reflection.klass))
55
- end
91
+ collection = @raw_choices || authorized_relation
56
92
  collection = collection.limit(@choice_limit) if @choice_limit && collection.respond_to?(:limit)
57
93
  build_choice_mapper(collection)
58
94
  end
59
95
  end
60
96
 
97
+ # Builds the authorized association relation. Shared by `choices`
98
+ # (which then applies `choice_limit`) and `normalize_simple_input`
99
+ # (which validates against the full set so typeahead picks beyond
100
+ # the rendered subset still survive submit).
101
+ def authorized_relation
102
+ klass = association_reflection.klass
103
+ relation = choices_from_association(klass)
104
+ return relation if @skip_authorization
105
+ authorized_resource_scope(klass, relation: relation)
106
+ end
107
+
61
108
  def build_attributes
62
109
  build_association_attributes
63
110
  super
111
+ # Stash; the URL helper needs view_context which only exists
112
+ # once we're rendering.
113
+ @typeahead_option = attributes.delete(:typeahead)
114
+ end
115
+
116
+ def before_template
117
+ super
118
+ configure_typeahead_attributes!(@typeahead_option)
64
119
  end
65
120
 
66
121
  def build_association_attributes
@@ -79,6 +134,20 @@ module Plutonium
79
134
  end
80
135
  end
81
136
 
137
+ private
138
+
139
+ # Polymorphic reflections raise NameError on #klass — they
140
+ # have no single target class to search, so opt out.
141
+ def typeahead_target_class
142
+ return nil unless association_reflection
143
+ return nil if association_reflection.respond_to?(:polymorphic?) && association_reflection.polymorphic?
144
+ association_reflection.klass
145
+ end
146
+
147
+ def typeahead_kind_and_name(_typeahead_option)
148
+ [:input, association_reflection.name]
149
+ end
150
+
82
151
  def build_singluar_association_attributes
83
152
  attributes.fetch(:input_param) { attributes[:input_param] = :"#{association_reflection.name}_sgid" }
84
153
  end
@@ -88,9 +157,21 @@ module Plutonium
88
157
  attributes[:multiple] = true
89
158
  end
90
159
 
160
+ # Validates a submitted SGID against the authorized association scope
161
+ # (not against `choices`, which is capped at `choice_limit` and may
162
+ # not include records reachable via typeahead). For explicit
163
+ # `@raw_choices`, fall back to membership in the rendered list.
91
164
  def normalize_simple_input(input_value)
92
- @signed_global_ids ||= choices.values.map { |choice| SignedGlobalID.parse(choice) }
93
- ([SignedGlobalID.parse(input_value.presence)].compact & @signed_global_ids)[0]
165
+ sgid = SignedGlobalID.parse(input_value.presence)
166
+ return nil unless sgid
167
+
168
+ if @raw_choices
169
+ @signed_global_ids ||= choices.values.map { |choice| SignedGlobalID.parse(choice) }
170
+ return @signed_global_ids.include?(sgid) ? sgid : nil
171
+ end
172
+
173
+ return nil unless sgid.model_class <= association_reflection.klass
174
+ authorized_relation.exists?(id: sgid.model_id) ? sgid : nil
94
175
  end
95
176
 
96
177
  def selected?(option)
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Concerns
7
+ # Shared typeahead wiring for association/resource select components.
8
+ #
9
+ # Hosts must implement two hooks:
10
+ #
11
+ # typeahead_target_class -> the associated model class, or nil
12
+ # (returns nil for polymorphic/unknown)
13
+ # typeahead_kind_and_name(opt) -> [:input | :filter, Symbol], used when
14
+ # the consumer didn't pass an explicit
15
+ # `typeahead: {kind:, name:}` hash
16
+ #
17
+ # The concern owns:
18
+ # - `configure_typeahead_attributes!` — call from `before_template`
19
+ # - `typeahead_searchable?` — registry + fallback-column check
20
+ # - `typeahead_url_for` — engine route helper lookup
21
+ module TypeaheadAttributes
22
+ extend ActiveSupport::Concern
23
+
24
+ private
25
+
26
+ # Adds the typeahead URL data attr so the slim-select Stimulus
27
+ # controller delegates to the backend via events.search.
28
+ # Default-on (opt out with `typeahead: false`). Pass a Hash to
29
+ # override kind/name (e.g. `typeahead: {kind: :filter, name: :status}`).
30
+ #
31
+ # Auto opt-out: if the associated resource has neither a `search`
32
+ # block nor a fallback search column on the model, fall back to
33
+ # slim-select's eager list + client-side filter — the backend
34
+ # would just return unfiltered records.
35
+ def configure_typeahead_attributes!(typeahead_option)
36
+ return if typeahead_option == false
37
+ return unless typeahead_searchable?
38
+ url = typeahead_url_for(typeahead_option)
39
+ return unless url
40
+ attributes[:data_slim_select_typeahead_url_value] = url
41
+ end
42
+
43
+ def typeahead_searchable?
44
+ klass = typeahead_target_class
45
+ return false unless klass
46
+
47
+ # Go through `resource_definition` so portal/package namespacing
48
+ # is honored — a portal can ship its own definition with a
49
+ # different `search` block than the base.
50
+ return true if resource_definition(klass).class._search_definition.present?
51
+
52
+ Plutonium::Resource::Controllers::Typeahead
53
+ .searchable_column_for(klass, label_method: @label_method).present?
54
+ rescue NameError
55
+ false
56
+ end
57
+
58
+ def typeahead_url_for(typeahead_option)
59
+ kind, name = if typeahead_option.is_a?(Hash)
60
+ [typeahead_option[:kind] || :input, typeahead_option[:name]]
61
+ else
62
+ typeahead_kind_and_name(typeahead_option)
63
+ end
64
+ return nil unless name
65
+
66
+ route_key = resource_class.model_name.route_key
67
+ helper = (kind == :filter) ? :"typeahead_filter_#{route_key}_path" : :"typeahead_input_#{route_key}_path"
68
+
69
+ # Engine route helpers are the source of truth for routes
70
+ # mounted under a Plutonium portal — phlex-rails' `helpers`
71
+ # proxy is deprecated and not the right entry point here.
72
+ # Helper may be absent if a consumer removed the typeahead
73
+ # route from the resource — fall back to no URL, slim-select
74
+ # uses its eager list.
75
+ url_helpers = current_engine.routes.url_helpers
76
+ return nil unless url_helpers.respond_to?(helper)
77
+ url_helpers.public_send(helper, name: name)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -47,7 +47,7 @@ module Plutonium
47
47
 
48
48
  def initialize_attributes
49
49
  super
50
- attributes[:id] = :interaction_form
50
+ attributes[:id] = "interaction-form"
51
51
  attributes.fetch(:data_turbo) { attributes[:data_turbo] = object.turbo.to_s }
52
52
  end
53
53
 
@@ -70,10 +70,6 @@ module Plutonium
70
70
  end
71
71
  end
72
72
 
73
- def in_modal?
74
- current_turbo_frame == Plutonium::REMOTE_MODAL_FRAME
75
- end
76
-
77
73
  def show_submit_and_continue?
78
74
  return false unless object.respond_to?(:new_record?)
79
75
 
@@ -14,7 +14,7 @@ module Plutonium
14
14
  inner_wrapper: "w-full",
15
15
 
16
16
  # Form errors
17
- form_errors_wrapper: "flex items-start gap-3 p-4 mb-6 text-base text-danger-800 rounded-[var(--pu-radius-lg)] bg-danger-50 border border-danger-200 dark:bg-danger-950/30 dark:border-danger-800 dark:text-danger-300",
17
+ form_errors_wrapper: "flex items-start gap-3 m-4 p-4 text-base text-danger-800 rounded-[var(--pu-radius-lg)] bg-danger-50 border border-danger-200 dark:bg-danger-950/30 dark:border-danger-800 dark:text-danger-300",
18
18
  form_errors_message: "font-semibold",
19
19
  form_errors_list: "mt-2 list-disc list-inside text-sm",
20
20
 
@@ -44,7 +44,7 @@ module Plutonium
44
44
  query: current_query_object,
45
45
  search_url: request.path,
46
46
  search_value: params.dig(:q, :search) || params[:search],
47
- views: resource_definition.defined_views,
47
+ views: resource_definition.defined_index_views,
48
48
  current_view: :grid,
49
49
  view_cookie_name: Plutonium::UI::Page::Index.view_cookie_name(resource_class),
50
50
  view_cookie_path: Plutonium::UI::Page::Index.view_cookie_path(request)
@@ -105,6 +105,7 @@ module Plutonium
105
105
 
106
106
  def render_after_main
107
107
  turbo_frame_tag(Plutonium::REMOTE_MODAL_FRAME)
108
+ turbo_frame_tag(Plutonium::REMOTE_MODAL_SECONDARY_FRAME)
108
109
  end
109
110
 
110
111
  def render_content(&)
@@ -90,13 +90,6 @@ module Plutonium
90
90
  # Returns false by default; pages opt-in by overriding.
91
91
  def aside_present? = false
92
92
 
93
- # True when the page is rendered inside any turbo frame.
94
- def in_frame? = current_turbo_frame.present?
95
-
96
- # True when the page is rendered inside the remote_modal turbo frame.
97
- # Used by form pages to suppress the sticky footer (modal owns its own footer).
98
- def in_modal? = current_turbo_frame == Plutonium::REMOTE_MODAL_FRAME
99
-
100
93
  # Customization hooks
101
94
  def render_before_header
102
95
  end
@@ -7,7 +7,7 @@ module Plutonium
7
7
  private
8
8
 
9
9
  def page_title
10
- current_definition.edit_page_title || super || "Edit"
10
+ current_definition.edit_page_title || super || "Edit #{resource_name(resource_class, 1)}"
11
11
  end
12
12
 
13
13
  def page_description
@@ -46,11 +46,11 @@ module Plutonium
46
46
  # Resolution order:
47
47
  # 1. `?view=` URL param (so a shared link can pin a view)
48
48
  # 2. The view-preference cookie (sticky per-resource selection)
49
- # 3. The resource's `default_view` (which itself defaults to
50
- # `views.first`)
49
+ # 3. The resource's `default_index_view` (which itself defaults to
50
+ # `index_views.first`)
51
51
  def selected_view
52
52
  definition = current_definition
53
- enabled = definition.defined_views
53
+ enabled = definition.defined_index_views
54
54
 
55
55
  requested = params[:view]&.to_sym
56
56
  return requested if requested && enabled.include?(requested)
@@ -58,7 +58,7 @@ module Plutonium
58
58
  stored = helpers.cookies[self.class.view_cookie_name(resource_class)]&.to_sym
59
59
  return stored if stored && enabled.include?(stored)
60
60
 
61
- definition.default_view
61
+ definition.default_index_view
62
62
  end
63
63
 
64
64
  def page_type = :index_page
@@ -7,7 +7,7 @@ module Plutonium
7
7
  private
8
8
 
9
9
  def page_title
10
- current_definition.new_page_title || super || "New"
10
+ current_definition.new_page_title || super || "New #{resource_name(resource_class, 1)}"
11
11
  end
12
12
 
13
13
  def page_description
@@ -14,10 +14,7 @@ module Plutonium
14
14
  def initialize(*, query_object:, search_url:, search_param: :q, search_value: nil, attributes: {}, **opts, &)
15
15
  opts[:as] = :q
16
16
  opts[:method] = :get
17
- attributes = attributes.deep_merge(
18
- id: "filter-form",
19
- data: {turbo_frame: nil}
20
- )
17
+ attributes = attributes.deep_merge(data: {turbo_frame: nil})
21
18
  super(*, attributes:, **opts, &)
22
19
  @query_object = query_object
23
20
  @search_url = search_url
@@ -25,6 +22,17 @@ module Plutonium
25
22
  @search_value = search_value
26
23
  end
27
24
 
25
+ # The Filters slideover renders on every index page (off-screen
26
+ # until opened) — same DOM as any CRUD modal that might appear.
27
+ # Base defaults the form id to "resource-form"; without this
28
+ # override, document-level `turbo_stream.replace("resource-form", …)`
29
+ # from a CRUD submit would clobber the wrong form. Pick a distinct
30
+ # id so the two never collide.
31
+ def initialize_attributes
32
+ super
33
+ attributes[:id] = "filter-form"
34
+ end
35
+
28
36
  def form_class
29
37
  "flex-1 flex flex-col min-h-0"
30
38
  end
@@ -44,7 +44,7 @@ module Plutonium
44
44
  query: current_query_object,
45
45
  search_url: current_search_url,
46
46
  search_value: params.dig(:q, :search) || params[:search],
47
- views: resource_definition.defined_views,
47
+ views: resource_definition.defined_index_views,
48
48
  current_view: :table,
49
49
  view_cookie_name: Plutonium::UI::Page::Index.view_cookie_name(resource_class),
50
50
  view_cookie_path: Plutonium::UI::Page::Index.view_cookie_path(request)
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.50.0"
2
+ VERSION = "0.52.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/lib/plutonium.rb CHANGED
@@ -28,6 +28,14 @@ module Plutonium
28
28
  # frame name lives in one place.
29
29
  REMOTE_MODAL_FRAME = "remote_modal"
30
30
 
31
+ # Secondary modal frame, used to stack a modal on top of the primary one
32
+ # (e.g. clicking the inline "+" next to an association field while the
33
+ # parent form is itself rendered in the primary modal). The layout
34
+ # renders a second frame, and `in_modal?` recognises both.
35
+ REMOTE_MODAL_SECONDARY_FRAME = "remote_modal_secondary"
36
+
37
+ MODAL_FRAMES = [REMOTE_MODAL_FRAME, REMOTE_MODAL_SECONDARY_FRAME].freeze
38
+
31
39
  # Set up Zeitwerk loader for the Plutonium gem
32
40
  # @return [Zeitwerk::Loader] configured Zeitwerk loader instance
33
41
  Loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false).tap do |loader|
@@ -102,7 +102,11 @@ namespace :release do
102
102
  desc "Build front-end assets"
103
103
  task :build_frontend do
104
104
  puts "Building front-end assets..."
105
- system("yarn build") || abort("Front-end build failed")
105
+ # in: File::NULL — yarn 4 puts the terminal in raw mode for its
106
+ # progress UI and doesn't always restore it on exit. Without this,
107
+ # subsequent `$stdin.gets` prompts read one keystroke at a time and
108
+ # never see a newline, so Enter never terminates the line.
109
+ system("yarn build", in: File::NULL) || abort("Front-end build failed")
106
110
  puts "✓ Built front-end assets"
107
111
  end
108
112
 
@@ -160,6 +164,14 @@ namespace :release do
160
164
  exit 1
161
165
  end
162
166
 
167
+ # Snapshot the terminal mode up front. yarn 4 and git-cliff both put
168
+ # the TTY in raw mode for progress UIs and don't always restore it,
169
+ # which breaks every subsequent `$stdin.gets` (Enter arrives as a
170
+ # bare \r and gets() never returns). Restore the snapshot before each
171
+ # prompt so the user can actually answer.
172
+ tty_state = `stty -g 2>/dev/null`.strip
173
+ restore_tty = -> { system("stty #{tty_state} 2>/dev/null") if tty_state != "" }
174
+
163
175
  puts "Starting release workflow for v#{version}..."
164
176
 
165
177
  # Check npm authentication early, login if needed
@@ -179,6 +191,7 @@ namespace :release do
179
191
  current_branch = `git branch --show-current`.strip
180
192
  unless current_branch == "main" || current_branch == "master"
181
193
  puts "Warning: You're not on main/master branch (current: #{current_branch})"
194
+ restore_tty.call
182
195
  print "Continue anyway? [y/N] "
183
196
  exit 1 unless $stdin.gets.strip.downcase == "y"
184
197
  end
@@ -187,6 +200,7 @@ namespace :release do
187
200
  Rake::Task["release:prepare"].invoke(version)
188
201
 
189
202
  # Confirm before proceeding
203
+ restore_tty.call
190
204
  puts "\nReady to commit, tag, and publish?"
191
205
  print "Continue? [y/N] "
192
206
  exit 0 unless $stdin.gets.strip.downcase == "y"
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.50.0",
3
+ "version": "0.52.0",
4
4
  "description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
@@ -25,27 +25,30 @@
25
25
  "@uppy/dashboard": "^4.1.3",
26
26
  "@uppy/image-editor": "^3.2.1",
27
27
  "@uppy/xhr-upload": "^4.2.3",
28
- "dompurify": "^3.2.2",
28
+ "dompurify": "^3.4.3",
29
29
  "lodash.debounce": "^4.0.8",
30
30
  "marked": "^15.0.3"
31
31
  },
32
32
  "devDependencies": {
33
+ "@tabler/icons-vue": "^3.44.0",
33
34
  "@tailwindcss/forms": "^0.5.10",
34
- "@tailwindcss/postcss": "^4.0.9",
35
+ "@tailwindcss/postcss": "^4.3.0",
35
36
  "@tailwindcss/typography": "^0.5.16",
37
+ "asciinema-player": "^3.15.1",
36
38
  "chokidar-cli": "^3.0.0",
37
39
  "concurrently": "^8.2.2",
38
40
  "cssnano": "^7.0.2",
39
- "esbuild": "^0.20.1",
41
+ "esbuild": "^0.28.0",
40
42
  "esbuild-plugin-manifest": "^1.0.3",
41
43
  "flowbite-typography": "^1.0.5",
42
- "mermaid": "^11.4.0",
43
- "postcss": "^8.4.35",
44
- "postcss-cli": "^11.0.0",
44
+ "medium-zoom": "^1.1.0",
45
+ "mermaid": "^11.15.0",
46
+ "postcss": "^8.5.14",
47
+ "postcss-cli": "^11.0.1",
45
48
  "postcss-hash": "^3.0.0",
46
- "postcss-import": "^16.1.0",
47
- "tailwindcss": "^4.0.9",
48
- "vitepress": "^1.4.1",
49
+ "postcss-import": "^16.1.1",
50
+ "tailwindcss": "^4.3.0",
51
+ "vitepress": "^1.6.4",
49
52
  "vitepress-plugin-mermaid": "^2.0.17"
50
53
  },
51
54
  "scripts": {
@@ -151,6 +151,10 @@
151
151
  @apply absolute flex h-auto flex-col w-auto max-h-72 border transition-all duration-200 opacity-0 z-[10000] overflow-hidden;
152
152
  background-color: var(--pu-surface);
153
153
  border-color: var(--pu-border);
154
+ /* Default text color for everything inside the panel — covers the
155
+ "No Results" text and any other slim-select chrome that doesn't
156
+ declare its own color rule. Specific rules below still win. */
157
+ color: var(--pu-text);
154
158
  box-shadow: var(--pu-shadow-md);
155
159
  transform: scaleY(0);
156
160
  transform-origin: top;