plutonium 0.49.1 → 0.51.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 (206) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +572 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +163 -300
  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 +655 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +6 -5
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +37 -0
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1323 -1184
  14. data/app/assets/plutonium.js.map +4 -4
  15. data/app/assets/plutonium.min.js +50 -49
  16. data/app/assets/plutonium.min.js.map +4 -4
  17. data/app/views/plutonium/_resource_header.html.erb +4 -4
  18. data/app/views/plutonium/_resource_sidebar.html.erb +9 -9
  19. data/app/views/resource/_resource_grid.html.erb +1 -0
  20. data/config/brakeman.ignore +25 -2
  21. data/docs/.vitepress/config.ts +37 -27
  22. data/docs/getting-started/index.md +22 -29
  23. data/docs/getting-started/installation.md +37 -80
  24. data/docs/getting-started/tutorial/index.md +4 -5
  25. data/docs/guides/adding-resources.md +66 -377
  26. data/docs/guides/authentication.md +94 -463
  27. data/docs/guides/authorization.md +124 -370
  28. data/docs/guides/creating-packages.md +94 -296
  29. data/docs/guides/custom-actions.md +121 -441
  30. data/docs/guides/index.md +22 -42
  31. data/docs/guides/multi-tenancy.md +116 -187
  32. data/docs/guides/nested-resources.md +103 -431
  33. data/docs/guides/search-filtering.md +123 -240
  34. data/docs/guides/testing.md +5 -4
  35. data/docs/guides/theming.md +157 -407
  36. data/docs/guides/troubleshooting.md +5 -3
  37. data/docs/guides/user-invites.md +106 -425
  38. data/docs/guides/user-profile.md +76 -243
  39. data/docs/index.md +1 -1
  40. data/docs/reference/app/generators.md +517 -0
  41. data/docs/reference/app/index.md +158 -0
  42. data/docs/reference/app/packages.md +146 -0
  43. data/docs/reference/app/portals.md +377 -0
  44. data/docs/reference/auth/accounts.md +230 -0
  45. data/docs/reference/auth/index.md +88 -0
  46. data/docs/reference/auth/profile.md +185 -0
  47. data/docs/reference/behavior/controllers.md +395 -0
  48. data/docs/reference/behavior/index.md +22 -0
  49. data/docs/reference/behavior/interactions.md +341 -0
  50. data/docs/reference/behavior/policies.md +417 -0
  51. data/docs/reference/index.md +56 -49
  52. data/docs/reference/resource/actions.md +423 -0
  53. data/docs/reference/resource/definition.md +508 -0
  54. data/docs/reference/resource/index.md +50 -0
  55. data/docs/reference/resource/model.md +348 -0
  56. data/docs/reference/resource/query.md +305 -0
  57. data/docs/reference/tenancy/entity-scoping.md +361 -0
  58. data/docs/reference/tenancy/index.md +36 -0
  59. data/docs/reference/tenancy/invites.md +393 -0
  60. data/docs/reference/tenancy/nested-resources.md +267 -0
  61. data/docs/reference/testing/index.md +287 -0
  62. data/docs/reference/ui/assets.md +400 -0
  63. data/docs/reference/ui/components.md +165 -0
  64. data/docs/reference/ui/displays.md +104 -0
  65. data/docs/reference/ui/forms.md +284 -0
  66. data/docs/reference/ui/index.md +30 -0
  67. data/docs/reference/ui/layouts.md +106 -0
  68. data/docs/reference/ui/pages.md +189 -0
  69. data/docs/reference/ui/tables.md +117 -0
  70. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md +841 -0
  71. data/docs/superpowers/plans/2026-05-07-ui-layout-overhaul.md.tasks.json +103 -0
  72. data/docs/superpowers/specs/2026-05-07-ui-layout-overhaul-design.md +270 -0
  73. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  74. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  75. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  76. data/gemfiles/rails_7.gemfile.lock +1 -1
  77. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  78. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  79. data/lib/generators/pu/core/install/templates/config/initializers/plutonium.rb +1 -0
  80. data/lib/generators/pu/invites/install_generator.rb +1 -0
  81. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +54 -5
  82. data/lib/plutonium/action/base.rb +44 -1
  83. data/lib/plutonium/action/interactive.rb +1 -1
  84. data/lib/plutonium/configuration.rb +4 -0
  85. data/lib/plutonium/definition/actions.rb +3 -0
  86. data/lib/plutonium/definition/base.rb +8 -0
  87. data/lib/plutonium/definition/index_views.rb +95 -0
  88. data/lib/plutonium/definition/metadata.rb +40 -0
  89. data/lib/plutonium/helpers/turbo_helper.rb +12 -1
  90. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  91. data/lib/plutonium/interaction/response/redirect.rb +1 -1
  92. data/lib/plutonium/query/base.rb +8 -0
  93. data/lib/plutonium/query/filters/association.rb +30 -8
  94. data/lib/plutonium/query/filters/boolean.rb +5 -0
  95. data/lib/plutonium/resource/controller.rb +1 -0
  96. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  97. data/lib/plutonium/resource/controllers/presentable.rb +11 -2
  98. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  99. data/lib/plutonium/resource/definition.rb +42 -0
  100. data/lib/plutonium/resource/policy.rb +7 -0
  101. data/lib/plutonium/resource/query_object.rb +64 -6
  102. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  103. data/lib/plutonium/testing/resource_definition.rb +2 -2
  104. data/lib/plutonium/ui/action_button.rb +4 -2
  105. data/lib/plutonium/ui/component/kit.rb +12 -0
  106. data/lib/plutonium/ui/component/methods.rb +4 -0
  107. data/lib/plutonium/ui/display/base.rb +3 -1
  108. data/lib/plutonium/ui/display/resource.rb +109 -25
  109. data/lib/plutonium/ui/display/theme.rb +2 -1
  110. data/lib/plutonium/ui/dyna_frame/content.rb +8 -14
  111. data/lib/plutonium/ui/empty_card.rb +1 -1
  112. data/lib/plutonium/ui/form/base.rb +35 -3
  113. data/lib/plutonium/ui/form/components/hidden_wrapper.rb +25 -0
  114. data/lib/plutonium/ui/form/components/json.rb +58 -0
  115. data/lib/plutonium/ui/form/components/resource_select.rb +133 -1
  116. data/lib/plutonium/ui/form/components/secure_association.rb +105 -24
  117. data/lib/plutonium/ui/form/components/sticky_footer.rb +17 -0
  118. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  119. data/lib/plutonium/ui/form/resource.rb +45 -10
  120. data/lib/plutonium/ui/form/theme.rb +1 -1
  121. data/lib/plutonium/ui/frame_navigator_panel.rb +7 -4
  122. data/lib/plutonium/ui/grid/card.rb +235 -0
  123. data/lib/plutonium/ui/grid/resource.rb +149 -0
  124. data/lib/plutonium/ui/layout/base.rb +38 -1
  125. data/lib/plutonium/ui/layout/header.rb +1 -2
  126. data/lib/plutonium/ui/layout/icon_rail.rb +212 -0
  127. data/lib/plutonium/ui/layout/resource_layout.rb +10 -3
  128. data/lib/plutonium/ui/layout/sidebar.rb +12 -24
  129. data/lib/plutonium/ui/layout/topbar.rb +100 -0
  130. data/lib/plutonium/ui/modal/base.rb +109 -0
  131. data/lib/plutonium/ui/modal/centered.rb +21 -0
  132. data/lib/plutonium/ui/modal/slideover.rb +26 -0
  133. data/lib/plutonium/ui/page/base.rb +18 -6
  134. data/lib/plutonium/ui/page/edit.rb +13 -1
  135. data/lib/plutonium/ui/page/index.rb +40 -1
  136. data/lib/plutonium/ui/page/interactive_action.rb +8 -39
  137. data/lib/plutonium/ui/page/new.rb +13 -1
  138. data/lib/plutonium/ui/page/show.rb +8 -1
  139. data/lib/plutonium/ui/page_header.rb +8 -13
  140. data/lib/plutonium/ui/panel.rb +10 -19
  141. data/lib/plutonium/ui/sidebar_menu.rb +2 -25
  142. data/lib/plutonium/ui/tab_list.rb +29 -7
  143. data/lib/plutonium/ui/table/base.rb +106 -0
  144. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +12 -4
  145. data/lib/plutonium/ui/table/components/filter_form.rb +171 -0
  146. data/lib/plutonium/ui/table/components/filter_pills.rb +89 -0
  147. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +13 -12
  148. data/lib/plutonium/ui/table/components/scopes_pills.rb +67 -0
  149. data/lib/plutonium/ui/table/components/selection_column.rb +2 -11
  150. data/lib/plutonium/ui/table/components/toolbar.rb +104 -0
  151. data/lib/plutonium/ui/table/components/view_switcher.rb +81 -0
  152. data/lib/plutonium/ui/table/resource.rb +158 -89
  153. data/lib/plutonium/ui/table/theme.rb +14 -5
  154. data/lib/plutonium/version.rb +1 -1
  155. data/lib/plutonium.rb +14 -0
  156. data/lib/tasks/release.rake +15 -1
  157. data/package.json +10 -10
  158. data/src/css/components.css +304 -131
  159. data/src/css/slim_select.css +4 -0
  160. data/src/css/tokens.css +101 -85
  161. data/src/js/controllers/autosubmit_controller.js +24 -0
  162. data/src/js/controllers/bulk_actions_controller.js +15 -16
  163. data/src/js/controllers/capture_url_controller.js +14 -0
  164. data/src/js/controllers/filter_panel_controller.js +77 -19
  165. data/src/js/controllers/frame_navigator_controller.js +34 -6
  166. data/src/js/controllers/icon_rail_controller.js +22 -0
  167. data/src/js/controllers/icon_rail_flyout_controller.js +128 -0
  168. data/src/js/controllers/register_controllers.js +16 -0
  169. data/src/js/controllers/resource_tab_list_controller.js +56 -3
  170. data/src/js/controllers/row_click_controller.js +21 -0
  171. data/src/js/controllers/slim_select_controller.js +61 -0
  172. data/src/js/controllers/table_column_menu_controller.js +43 -0
  173. data/src/js/controllers/table_header_controller.js +16 -0
  174. data/src/js/controllers/view_switcher_controller.js +29 -0
  175. data/src/js/turbo/turbo_actions.js +33 -0
  176. data/yarn.lock +553 -543
  177. metadata +71 -32
  178. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  179. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  180. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  181. data/.claude/skills/plutonium-definition/SKILL.md +0 -1138
  182. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  183. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  184. data/.claude/skills/plutonium-installation/SKILL.md +0 -325
  185. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  186. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  187. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  188. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  189. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  190. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  191. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  192. data/.claude/skills/plutonium-views/SKILL.md +0 -592
  193. data/docs/reference/assets/index.md +0 -496
  194. data/docs/reference/controller/index.md +0 -412
  195. data/docs/reference/definition/actions.md +0 -449
  196. data/docs/reference/definition/fields.md +0 -383
  197. data/docs/reference/definition/index.md +0 -268
  198. data/docs/reference/definition/query.md +0 -351
  199. data/docs/reference/generators/index.md +0 -648
  200. data/docs/reference/interaction/index.md +0 -449
  201. data/docs/reference/model/features.md +0 -248
  202. data/docs/reference/model/index.md +0 -218
  203. data/docs/reference/policy/index.md +0 -456
  204. data/docs/reference/portal/index.md +0 -379
  205. data/docs/reference/views/forms.md +0 -411
  206. data/docs/reference/views/index.md +0 -501
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Definition
5
+ # Adds the `metadata` DSL — a list of field names rendered in the
6
+ # show page's right-side panel as label/value rows. Opt-in: when no
7
+ # `metadata` call is made, the show page stays full-width with no
8
+ # aside.
9
+ #
10
+ # @example
11
+ # class PostDefinition < Plutonium::Definition::Base
12
+ # metadata :created_at, :updated_at, :author, :state
13
+ # end
14
+ module Metadata
15
+ extend ActiveSupport::Concern
16
+
17
+ included do
18
+ class_attribute :defined_metadata_fields, default: [], instance_accessor: false
19
+ end
20
+
21
+ class_methods do
22
+ # Declares the fields rendered in the show page metadata panel.
23
+ # Each name is looked up in `defined_fields` for display config
24
+ # (label/format), so a field can have custom formatting in the
25
+ # main show body and the panel without redeclaring.
26
+ #
27
+ # @param names [Array<Symbol>]
28
+ def metadata(*names)
29
+ self.defined_metadata_fields = names.flatten.map(&:to_sym)
30
+ end
31
+ end
32
+
33
+ # class_attribute is declared with instance_accessor: false; expose
34
+ # an instance reader that delegates so callers with a definition
35
+ # instance (e.g. `current_definition`) can ask without poking the
36
+ # class directly. Mirrors Definition::Views.
37
+ def defined_metadata_fields = self.class.defined_metadata_fields
38
+ end
39
+ end
40
+ end
@@ -5,8 +5,19 @@ 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
- turbo_frame_tag("remote_modal", &)
20
+ turbo_frame_tag(Plutonium::REMOTE_MODAL_FRAME, &)
10
21
  end
11
22
  end
12
23
  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)
@@ -29,7 +29,7 @@ module Plutonium
29
29
 
30
30
  respond_to do |format|
31
31
  format.turbo_stream do
32
- if helpers.current_turbo_frame == "remote_modal"
32
+ if helpers.current_turbo_frame == Plutonium::REMOTE_MODAL_FRAME
33
33
  render turbo_stream: [
34
34
  helpers.turbo_stream_redirect(url)
35
35
  ]
@@ -24,6 +24,14 @@ module Plutonium
24
24
  def apply(scope, **params)
25
25
  raise NotImplementedError, "#{self.class}#apply(scope, **params)"
26
26
  end
27
+
28
+ # Human-readable rendering of a single filter value for the active
29
+ # filter pill row. Defaults to `value.to_s`. Subclasses
30
+ # (Filters::Association, Filters::Boolean) override to translate
31
+ # raw param values (SGIDs, "true"/"false") into recognisable text.
32
+ def humanize_value(value)
33
+ value.to_s
34
+ end
27
35
  end
28
36
  end
29
37
  end
@@ -16,7 +16,7 @@ module Plutonium
16
16
  # filter :user, with: :association, class_name: User, scope: ->(s) { s.active }
17
17
  #
18
18
  class Association < Filter
19
- def initialize(class_name: nil, resource_class: nil, scope: nil, multiple: false, **)
19
+ def initialize(class_name: nil, resource_class: nil, scope: nil, multiple: true, **)
20
20
  super(**)
21
21
  @class_name = class_name
22
22
  @resource_class = resource_class
@@ -24,15 +24,21 @@ module Plutonium
24
24
  @multiple = multiple
25
25
  end
26
26
 
27
+ def humanize_value(value)
28
+ return "" if value.blank?
29
+ ids = decode_ids(value)
30
+ return "" if ids.empty?
31
+ records = association_class.where(id: ids)
32
+ records.map { |r| r.respond_to?(:to_label) ? r.to_label : r.to_s }.join(", ")
33
+ rescue
34
+ Array(value).reject(&:blank?).join(", ")
35
+ end
36
+
27
37
  def apply(scope, value:)
28
38
  return scope if value.blank?
29
-
30
- foreign_key = :"#{key}_id"
31
- if @multiple && value.is_a?(Array)
32
- scope.where(foreign_key => value.reject(&:blank?))
33
- else
34
- scope.where(foreign_key => value)
35
- end
39
+ ids = decode_ids(value)
40
+ return scope if ids.empty?
41
+ scope.where("#{key}_id": ids)
36
42
  end
37
43
 
38
44
  def customize_inputs
@@ -45,6 +51,22 @@ module Plutonium
45
51
 
46
52
  private
47
53
 
54
+ # Accepts either an SGID (the new default sent by ResourceSelect)
55
+ # or a raw id (legacy URLs). Returns the underlying record ids.
56
+ def decode_ids(value)
57
+ Array(value).reject(&:blank?).filter_map { |v| decode_id(v) }
58
+ end
59
+
60
+ def decode_id(value)
61
+ gid = SignedGlobalID.parse(value)
62
+ return gid.model_id if gid
63
+ value
64
+ rescue
65
+ value
66
+ end
67
+
68
+ private
69
+
48
70
  def association_class
49
71
  @association_class ||= resolve_class_name || detect_class_from_reflection || infer_class_from_key
50
72
  end
@@ -16,6 +16,11 @@ module Plutonium
16
16
  @false_label = false_label
17
17
  end
18
18
 
19
+ def humanize_value(value)
20
+ return "" if value.blank?
21
+ ActiveModel::Type::Boolean.new.cast(value) ? @true_label : @false_label
22
+ end
23
+
19
24
  def apply(scope, value:)
20
25
  return scope if value.blank?
21
26
 
@@ -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 }
@@ -58,7 +58,7 @@ module Plutonium
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,
@@ -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
@@ -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, **)
@@ -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
@@ -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
@@ -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.
@@ -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
 
@@ -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
@@ -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,