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,17 +7,15 @@ module Plutonium
7
7
  #
8
8
  # @example Enable both views, default to Grid
9
9
  # class UserDefinition < Plutonium::Resource::Definition
10
- # views :table, :grid
11
- # default_view :grid
12
- #
13
10
  # grid_fields(
14
11
  # image: :avatar,
15
12
  # header: :name,
16
13
  # subheader: :email,
17
14
  # meta: [:role, :status]
18
15
  # )
16
+ # default_index_view :grid
19
17
  # end
20
- module Views
18
+ module IndexViews
21
19
  extend ActiveSupport::Concern
22
20
 
23
21
  KNOWN_VIEWS = %i[table grid].freeze
@@ -25,8 +23,8 @@ module Plutonium
25
23
  GRID_LAYOUTS = %i[compact media].freeze
26
24
 
27
25
  included do
28
- class_attribute :defined_views, default: [:table], instance_accessor: false
29
- class_attribute :defined_default_view, default: nil, instance_accessor: false
26
+ class_attribute :defined_index_views, default: [:table], instance_accessor: false
27
+ class_attribute :defined_default_index_view, default: nil, instance_accessor: false
30
28
  class_attribute :defined_grid_fields, default: {}, instance_accessor: false
31
29
  class_attribute :defined_grid_layout, default: :compact, instance_accessor: false
32
30
  class_attribute :defined_grid_columns, default: nil, instance_accessor: false
@@ -34,37 +32,40 @@ module Plutonium
34
32
 
35
33
  class_methods do
36
34
  # Declares the index views this resource supports.
35
+ # Usually unnecessary — declaring `grid_fields` auto-enables :grid
36
+ # alongside the default :table. Use `index_views` only to disable
37
+ # one (e.g. `index_views :grid` to drop the table view).
37
38
  # @param list [Array<Symbol>] one or more of {KNOWN_VIEWS}
38
- def views(*list)
39
+ def index_views(*list)
39
40
  list = list.flatten.map(&:to_sym)
40
41
  invalid = list - KNOWN_VIEWS
41
- raise ArgumentError, "Unknown views: #{invalid.inspect}. Valid: #{KNOWN_VIEWS}" if invalid.any?
42
- self.defined_views = list.empty? ? [:table] : list
42
+ raise ArgumentError, "Unknown index_views: #{invalid.inspect}. Valid: #{KNOWN_VIEWS}" if invalid.any?
43
+ self.defined_index_views = list.empty? ? [:table] : list
43
44
  end
44
45
 
45
- # Declares the default index view. Must be one of {.views}.
46
+ # Declares the default index view. Must be one of {.index_views}.
46
47
  # Falls back to the first declared view if unset.
47
- def default_view(name = nil)
48
+ def default_index_view(name = nil)
48
49
  if name.nil?
49
- defined_default_view || defined_views.first
50
+ defined_default_index_view || defined_index_views.first
50
51
  else
51
52
  name = name.to_sym
52
- unless defined_views.include?(name)
53
- raise ArgumentError, "default_view #{name.inspect} not in views #{defined_views.inspect}"
53
+ unless defined_index_views.include?(name)
54
+ raise ArgumentError, "default_index_view #{name.inspect} not in index_views #{defined_index_views.inspect}"
54
55
  end
55
- self.defined_default_view = name
56
+ self.defined_default_index_view = name
56
57
  end
57
58
  end
58
59
 
59
60
  # Maps grid slots to fields. Each slot is optional. Implicitly
60
- # adds `:grid` to {.views} so a resource can opt into the Grid
61
- # view simply by declaring its slots.
61
+ # adds `:grid` to {.index_views} so a resource can opt into the
62
+ # Grid view simply by declaring its slots.
62
63
  # @param slots [Hash{Symbol => Symbol, Array<Symbol>}]
63
64
  def grid_fields(**slots)
64
65
  invalid = slots.keys - GRID_SLOTS
65
66
  raise ArgumentError, "Unknown grid slots: #{invalid.inspect}. Valid: #{GRID_SLOTS}" if invalid.any?
66
67
  self.defined_grid_fields = slots
67
- self.defined_views = defined_views + [:grid] unless defined_views.include?(:grid)
68
+ self.defined_index_views = defined_index_views + [:grid] unless defined_index_views.include?(:grid)
68
69
  end
69
70
 
70
71
  # Layout shape for grid cards. :compact (default) places the image
@@ -84,8 +85,8 @@ module Plutonium
84
85
  end
85
86
  end
86
87
 
87
- def defined_views = self.class.defined_views
88
- def default_view = self.class.default_view
88
+ def defined_index_views = self.class.defined_index_views
89
+ def default_index_view = self.class.default_index_view
89
90
  def defined_grid_fields = self.class.defined_grid_fields
90
91
  def defined_grid_layout = self.class.defined_grid_layout
91
92
  def defined_grid_columns = self.class.defined_grid_columns
@@ -5,9 +5,39 @@ module Plutonium
5
5
  request.headers["Turbo-Frame"]
6
6
  end
7
7
 
8
+ # True when the request is rendered inside any turbo frame.
9
+ def in_frame? = current_turbo_frame.present?
10
+
11
+ # True when the request is rendered inside either modal frame
12
+ # (primary or secondary).
13
+ def in_modal? = Plutonium::MODAL_FRAMES.include?(current_turbo_frame)
14
+
15
+ # True when the request is rendered inside the secondary (stacked)
16
+ # modal frame specifically.
17
+ def in_secondary_modal? = current_turbo_frame == Plutonium::REMOTE_MODAL_SECONDARY_FRAME
18
+
8
19
  def remote_modal_frame_tag(&)
9
20
  turbo_frame_tag(Plutonium::REMOTE_MODAL_FRAME, &)
10
21
  end
22
+
23
+ # Returns a turbo-frame-scoped element id. Two identically-named forms
24
+ # can be on the page simultaneously (e.g. a primary modal opens a
25
+ # secondary modal, each rendering an `id="resource-form"`). When the
26
+ # server later replies with `turbo_stream.replace("resource-form", ...)`,
27
+ # Turbo would pick the FIRST element matching the id — which is rarely
28
+ # the one the user actually submitted. Append a frame suffix so each
29
+ # frame's form has a unique id and the controller can target precisely.
30
+ #
31
+ # @param base [String, Symbol] the base id
32
+ # @return [String] the scoped id (no suffix outside any modal frame)
33
+ def turbo_scoped_dom_id(base)
34
+ base = base.to_s
35
+ case current_turbo_frame
36
+ when Plutonium::REMOTE_MODAL_FRAME then "#{base}-primary"
37
+ when Plutonium::REMOTE_MODAL_SECONDARY_FRAME then "#{base}-secondary"
38
+ else base
39
+ end
40
+ end
11
41
  end
12
42
  end
13
43
  end
@@ -9,6 +9,20 @@ module Plutonium
9
9
  end
10
10
  end
11
11
 
12
+ # Closes the <dialog> inside the targeted frame and empties the
13
+ # frame. Used to dismiss a stacked modal without affecting the
14
+ # rest of the page.
15
+ def turbo_stream_close_frame(frame_id)
16
+ turbo_stream_action_tag :close_frame, target: frame_id
17
+ end
18
+
19
+ # Reloads the targeted frame from its current src. Used to refresh
20
+ # the primary modal after a secondary-modal action mutates data
21
+ # the primary depends on.
22
+ def turbo_stream_reload_frame(frame_id)
23
+ turbo_stream_action_tag :reload_frame, target: frame_id
24
+ end
25
+
12
26
  private
13
27
 
14
28
  def turbo_stream_redirect_same_page?(url)
@@ -15,6 +15,7 @@ module Plutonium
15
15
  include Plutonium::Resource::Controllers::Queryable
16
16
  include Plutonium::Resource::Controllers::CrudActions
17
17
  include Plutonium::Resource::Controllers::InteractiveActions
18
+ include Plutonium::Resource::Controllers::Typeahead
18
19
 
19
20
  included do
20
21
  after_action { response.headers.merge!(@pagy.headers_hash) if @pagy }
@@ -53,12 +53,12 @@ module Plutonium
53
53
 
54
54
  respond_to do |format|
55
55
  if params[:pre_submit]
56
- format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :new))) }
56
+ format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :new))) }
57
57
  format.html { render :new, status: :unprocessable_content }
58
58
  elsif resource_record!.save
59
59
  format.turbo_stream do
60
60
  flash.notice = "#{resource_class.model_name.human} was successfully created."
61
- render turbo_stream: helpers.turbo_stream_redirect(redirect_url_after_submit)
61
+ render turbo_stream: stacked_modal_create_streams
62
62
  end
63
63
  format.html do
64
64
  redirect_to redirect_url_after_submit,
@@ -71,7 +71,7 @@ module Plutonium
71
71
  location: redirect_url_after_submit
72
72
  end
73
73
  else
74
- format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :new))), status: :unprocessable_content }
74
+ format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :new))), status: :unprocessable_content }
75
75
  format.html { render :new, status: :unprocessable_content }
76
76
  format.any do
77
77
  @errors = resource_record!.errors
@@ -100,7 +100,7 @@ module Plutonium
100
100
 
101
101
  respond_to do |format|
102
102
  if params[:pre_submit]
103
- format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :edit))) }
103
+ format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :edit))) }
104
104
  format.html { render :edit, status: :unprocessable_content }
105
105
  elsif resource_record!.save
106
106
  format.turbo_stream do
@@ -116,7 +116,7 @@ module Plutonium
116
116
  render :show, status: :ok, location: redirect_url_after_submit
117
117
  end
118
118
  else
119
- format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :edit))), status: :unprocessable_content }
119
+ format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :edit))), status: :unprocessable_content }
120
120
  format.html { render :edit, status: :unprocessable_content }
121
121
  format.any do
122
122
  @errors = resource_record!.errors
@@ -164,6 +164,24 @@ module Plutonium
164
164
 
165
165
  private
166
166
 
167
+ # When the create came in through the secondary (stacked) modal
168
+ # frame — i.e. the user clicked "+" next to an association field
169
+ # while the parent form was already in a modal — we don't want to
170
+ # navigate anywhere. Close the secondary dialog and reload the
171
+ # primary modal frame so the just-created record appears in the
172
+ # association select. Outside that case fall back to the normal
173
+ # post-submit redirect.
174
+ def stacked_modal_create_streams
175
+ if helpers.in_secondary_modal?
176
+ [
177
+ helpers.turbo_stream_close_frame(Plutonium::REMOTE_MODAL_SECONDARY_FRAME),
178
+ helpers.turbo_stream_reload_frame(Plutonium::REMOTE_MODAL_FRAME)
179
+ ]
180
+ else
181
+ helpers.turbo_stream_redirect(redirect_url_after_submit)
182
+ end
183
+ end
184
+
167
185
  def redirect_url_after_submit
168
186
  if (return_to = url_from(params[:return_to]))
169
187
  return return_to
@@ -38,7 +38,7 @@ module Plutonium
38
38
 
39
39
  if params[:pre_submit]
40
40
  respond_to do |format|
41
- format.turbo_stream { render turbo_stream: turbo_stream.replace("interaction-form", view_context.render(@interaction.build_form)) }
41
+ format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("interaction-form"), view_context.render(@interaction.build_form)) }
42
42
  format.html { render :interactive_record_action, formats: [:html], status: :unprocessable_content }
43
43
  end
44
44
  return
@@ -87,7 +87,7 @@ module Plutonium
87
87
 
88
88
  if params[:pre_submit]
89
89
  respond_to do |format|
90
- format.turbo_stream { render turbo_stream: turbo_stream.replace("interaction-form", view_context.render(@interaction.build_form)) }
90
+ format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("interaction-form"), view_context.render(@interaction.build_form)) }
91
91
  format.html { render :interactive_resource_action, status: :unprocessable_content }
92
92
  end
93
93
  return
@@ -134,7 +134,7 @@ module Plutonium
134
134
 
135
135
  if params[:pre_submit]
136
136
  respond_to do |format|
137
- format.turbo_stream { render turbo_stream: turbo_stream.replace("interaction-form", view_context.render(@interaction.build_form)) }
137
+ format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("interaction-form"), view_context.render(@interaction.build_form)) }
138
138
  format.html { render :interactive_bulk_action, formats: [:html], status: :unprocessable_content }
139
139
  end
140
140
  return
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Resource
5
+ module Controllers
6
+ # Backend dispatch for typeahead/autocomplete queries against
7
+ # resource form inputs and index filter inputs. Auto-mounted on
8
+ # every Plutonium resource via the `interactive_resource_actions`
9
+ # routing concern (see Plutonium::Routing::MapperExtensions).
10
+ #
11
+ # The controller resolves what to query directly from the input
12
+ # definition + the model's association reflection — no widget
13
+ # indirection. Two source kinds are supported:
14
+ #
15
+ # 1. Static `choices: [...]` — case-insensitive substring filter.
16
+ # 2. Association — either `association_class:` set on the input,
17
+ # or inferred from `resource_class.reflect_on_association(name)`.
18
+ #
19
+ # Association queries route through the associated resource's
20
+ # `policy.relation_scope` so users only see records they can read.
21
+ module Typeahead
22
+ extend ActiveSupport::Concern
23
+
24
+ TYPEAHEAD_LIMIT = 50
25
+
26
+ # Priority list tried when the input doesn't tell us which
27
+ # column carries its label. Aligns with what `to_label` usually
28
+ # wraps. Used only as a last resort.
29
+ FALLBACK_SEARCH_COLUMNS = %w[name title label slug display_name email].freeze
30
+
31
+ # Returns the column to LIKE against when no `search` block is
32
+ # defined. Used by both the server (to build the WHERE clause)
33
+ # and the input component (to decide whether to attach the
34
+ # typeahead URL).
35
+ #
36
+ # Resolution order:
37
+ # 1. The input's `label_method` if it names a real column (so
38
+ # `input :user, label_method: :email` just works).
39
+ # 2. The first match from FALLBACK_SEARCH_COLUMNS.
40
+ # 3. nil — no usable column, server returns unfiltered.
41
+ #
42
+ # The fallback is fine for moderate tables but uses a leading-
43
+ # wildcard LIKE which can't be served by a b-tree index. For
44
+ # large tables, declare a `search` block that uses a trigram or
45
+ # full-text index instead.
46
+ def self.searchable_column_for(klass, label_method: nil)
47
+ cols = klass.column_names
48
+ if label_method && cols.include?(label_method.to_s)
49
+ return label_method.to_s
50
+ end
51
+ FALLBACK_SEARCH_COLUMNS.find { |c| cols.include?(c) }
52
+ end
53
+
54
+ # Escapes the SQL LIKE wildcards `%` and `_` (plus the escape
55
+ # char itself) so a user searching for "100%" doesn't match
56
+ # everything. The literal `!` is used as the ESCAPE character —
57
+ # unambiguous across sqlite/postgres/mysql, no backslash-quoting
58
+ # surprises.
59
+ LIKE_ESCAPE_CHAR = "!"
60
+ def self.escape_like(value)
61
+ value.to_s.gsub(/[!%_]/) { |c| "#{LIKE_ESCAPE_CHAR}#{c}" }
62
+ end
63
+
64
+ included do
65
+ before_action :authorize_typeahead!, only: %i[typeahead_input typeahead_filter]
66
+ # Read-only JSON; row-level auth is enforced inline through
67
+ # authorized_resource_scope, so the after_action verifier is
68
+ # redundant.
69
+ skip_verify_current_authorized_scope only: %i[typeahead_input typeahead_filter]
70
+ end
71
+
72
+ # GET /<resource>/typeahead/input/:name?q=...
73
+ def typeahead_input
74
+ field_name = params[:name].to_sym
75
+ defn = current_definition.defined_inputs[field_name]
76
+ # Inputs are often inferred from the model (no explicit
77
+ # `input :foo` in the definition). Accept the request when the
78
+ # field name maps to a real association even without an entry.
79
+ unless defn || resource_class.reflect_on_association(field_name)
80
+ return head(:not_found)
81
+ end
82
+
83
+ render_typeahead_response(defn || {}, field_name)
84
+ end
85
+
86
+ # GET /<resource>/typeahead/filter/:name?q=...
87
+ def typeahead_filter
88
+ filter = current_query_object.filter_definitions[params[:name].to_sym]
89
+ return head(:not_found) unless filter
90
+
91
+ defn = filter.defined_inputs[:value]
92
+ return head(:not_found) unless defn
93
+
94
+ render_typeahead_response(defn, params[:name].to_sym)
95
+ end
96
+
97
+ private
98
+
99
+ def render_typeahead_response(defn, field_name)
100
+ options = defn[:options] || {}
101
+ query = params[:q].to_s
102
+ candidates = collect_typeahead_candidates(options, field_name, query)
103
+
104
+ if candidates.nil?
105
+ return render(json: {error: "input has no typeahead source"}, status: :bad_request)
106
+ end
107
+
108
+ has_more = candidates.length > TYPEAHEAD_LIMIT
109
+ results = candidates.first(TYPEAHEAD_LIMIT).map { |row| serialize_typeahead_row(row) }
110
+ render json: {results: results, has_more: has_more}
111
+ end
112
+
113
+ # Returns the candidate list, or nil if the input has neither
114
+ # static choices nor a resolvable association class.
115
+ def collect_typeahead_candidates(options, field_name, query)
116
+ if options[:choices]
117
+ filter_static_choices(options[:choices], query)
118
+ elsif (klass = typeahead_association_class(options, field_name))
119
+ filter_association(klass, query, options)
120
+ end
121
+ end
122
+
123
+ def typeahead_association_class(options, field_name)
124
+ options[:association_class] ||
125
+ resource_class.reflect_on_association(field_name)&.klass
126
+ end
127
+
128
+ def filter_static_choices(choices, query)
129
+ return choices if query.blank?
130
+ q = query.downcase
131
+ choices.select { |label, _| label.to_s.downcase.include?(q) }
132
+ end
133
+
134
+ # Routes through the associated resource's policy.relation_scope
135
+ # so typeahead never surfaces records the user can't read, then
136
+ # narrows via the associated resource definition's `search` block
137
+ # when present. Without a search block, fall back to a case-
138
+ # insensitive LIKE on the first column in FALLBACK_SEARCH_COLUMNS
139
+ # that exists on the model (so a resource with a `name` column
140
+ # gets useful typeahead without declaring `search`). If neither
141
+ # search block nor fallback column is available, the relation is
142
+ # returned unfiltered (capped).
143
+ def filter_association(klass, query, options)
144
+ relation = options[:skip_authorization] ? klass.all : authorized_resource_scope(klass)
145
+ if query.present?
146
+ if (search_block = associated_definition_search_block(klass))
147
+ relation = search_block.call(relation, query)
148
+ elsif (col = Typeahead.searchable_column_for(klass, label_method: options[:label_method]))
149
+ quoted = klass.connection.quote_column_name(col)
150
+ pattern = "%#{Typeahead.escape_like(query.downcase)}%"
151
+ relation = relation.where("LOWER(#{quoted}) LIKE ? ESCAPE '#{Typeahead::LIKE_ESCAPE_CHAR}'", pattern)
152
+ end
153
+ end
154
+ relation.limit(Typeahead::TYPEAHEAD_LIMIT + 1).to_a
155
+ end
156
+
157
+ # Resolves the associated resource's `search` block, if declared.
158
+ # Goes through `resource_definition` so portal/package namespacing
159
+ # is honored (same fallback chain as the rest of the controller).
160
+ def associated_definition_search_block(klass)
161
+ resource_definition(klass).class._search_definition
162
+ rescue NameError
163
+ nil
164
+ end
165
+
166
+ def serialize_typeahead_row(row)
167
+ if row.is_a?(Array)
168
+ {value: row[1].to_s, label: row[0].to_s}
169
+ else
170
+ {value: row.to_signed_global_id.to_s, label: row.to_label}
171
+ end
172
+ end
173
+
174
+ def authorize_typeahead!
175
+ authorize_current! resource_class, to: :typeahead?
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -179,6 +179,13 @@ module Plutonium
179
179
  index?
180
180
  end
181
181
 
182
+ # Checks if typeahead/autocomplete queries are permitted.
183
+ #
184
+ # @return [Boolean] Delegates to index?.
185
+ def typeahead?
186
+ index?
187
+ end
188
+
182
189
  # Core attributes
183
190
 
184
191
  # Returns the permitted attributes for the create action.
@@ -41,6 +41,7 @@ module Plutonium
41
41
  concern :interactive_resource_actions do
42
42
  define_member_interactive_actions
43
43
  define_collection_interactive_actions
44
+ define_collection_typeahead_actions
44
45
  end
45
46
  end
46
47
 
@@ -161,6 +162,20 @@ module Plutonium
161
162
  as: :commit_interactive_resource_action
162
163
  end
163
164
  end
165
+
166
+ # Defines collection-level typeahead actions for resource form inputs
167
+ # and index filter inputs. Auto-mounted alongside record_actions and
168
+ # bulk_actions on every Plutonium resource.
169
+ #
170
+ # @return [void]
171
+ def define_collection_typeahead_actions
172
+ collection do
173
+ get "typeahead/input/:name", action: :typeahead_input,
174
+ as: :typeahead_input
175
+ get "typeahead/filter/:name", action: :typeahead_filter,
176
+ as: :typeahead_filter
177
+ end
178
+ end
164
179
  end
165
180
  end
166
181
  end
@@ -40,10 +40,15 @@ module Plutonium
40
40
  :current_user,
41
41
  :current_parent,
42
42
  :current_definition,
43
+ :resource_definition,
43
44
  :current_query_object,
44
45
  :raw_resource_query_params,
45
46
  :current_policy,
46
47
  :current_turbo_frame,
48
+ :in_frame?,
49
+ :in_modal?,
50
+ :in_secondary_modal?,
51
+ :turbo_scoped_dom_id,
47
52
  :current_interactive_action,
48
53
  :current_engine,
49
54
  :policy_for,
@@ -66,6 +66,10 @@ module Plutonium
66
66
  create_component(Components::KeyValueStore, :key_value_store, **, &)
67
67
  end
68
68
 
69
+ def json_input_tag(**, &)
70
+ create_component(Components::Json, :json, **, &)
71
+ end
72
+
69
73
  def resource_select_tag(**attributes, &)
70
74
  attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select")
71
75
  # class!: "" clears the underlying <select>'s themed classes
@@ -107,8 +111,8 @@ module Plutonium
107
111
  alias_method :date_tag, :flatpickr_tag
108
112
  alias_method :time_tag, :flatpickr_tag
109
113
  alias_method :rich_text_tag, :markdown_tag
110
- alias_method :json_tag, :textarea_tag
111
- alias_method :jsonb_tag, :textarea_tag
114
+ alias_method :json_tag, :json_input_tag
115
+ alias_method :jsonb_tag, :json_input_tag
112
116
  alias_method :hstore_tag, :key_value_store_tag
113
117
  alias_method :key_value_tag, :key_value_store_tag
114
118
  alias_method :association_tag, :secure_association_tag
@@ -145,9 +149,25 @@ module Plutonium
145
149
  def initialize_attributes
146
150
  super
147
151
 
148
- attributes[:id] ||= :resource_form
152
+ attributes[:id] ||= "resource-form"
149
153
  attributes["data-controller"] = "form"
150
154
  end
155
+
156
+ # Scope the form id to the current turbo frame at render time (we
157
+ # can't do this in `initialize_attributes` — Phlex hasn't started
158
+ # rendering yet, so `view_context` and the request headers aren't
159
+ # accessible). Primary and secondary modals can each host a form
160
+ # without colliding on document-level turbo-stream `replace target=`
161
+ # lookups. See Helpers::TurboHelper#turbo_scoped_dom_id.
162
+ #
163
+ # Also force-replace the id (Phlexi's `mix` would otherwise prepend
164
+ # `@namespace.dom_id`, producing space-separated ids like
165
+ # "q filter-form" which break document.getElementById lookups).
166
+ def form_attributes
167
+ attrs = super
168
+ attrs[:id] = turbo_scoped_dom_id(attributes[:id]) if attributes[:id]
169
+ attrs
170
+ end
151
171
  end
152
172
  end
153
173
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Plutonium
6
+ module UI
7
+ module Form
8
+ module Components
9
+ # Textarea-based input for `json` / `jsonb` columns.
10
+ #
11
+ # On render, serializes Hash/Array values to pretty JSON so users see
12
+ # valid JSON instead of Ruby `Hash#to_s` output (e.g. `{:k=>"v"}`).
13
+ # Strings are pretty-formatted if parseable, passed through verbatim
14
+ # otherwise — preserves an in-progress edit on form re-render.
15
+ #
16
+ # On submit, accepts either a JSON string (typed input) or a raw
17
+ # Hash/Array (e.g. a JSON-bodied API request that Rails has already
18
+ # parsed into params). Unparseable strings are passed through so model
19
+ # validation can surface the error, rather than being silently dropped.
20
+ class Json < Phlexi::Form::Components::Textarea
21
+ def view_template
22
+ textarea(**attributes) { serialized_value }
23
+ end
24
+
25
+ protected
26
+
27
+ def serialized_value
28
+ case (raw = field.value)
29
+ when nil then ""
30
+ when String then format_string(raw)
31
+ else JSON.pretty_generate(raw)
32
+ end
33
+ end
34
+
35
+ def format_string(str)
36
+ JSON.pretty_generate(JSON.parse(str))
37
+ rescue JSON::ParserError
38
+ str
39
+ end
40
+
41
+ def normalize_input(input_value)
42
+ case input_value
43
+ when nil then nil
44
+ when Hash, Array then input_value
45
+ when "" then nil
46
+ else
47
+ begin
48
+ JSON.parse(input_value)
49
+ rescue JSON::ParserError
50
+ input_value
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end