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
@@ -6,6 +6,9 @@ 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
10
+
11
+ DEFAULT_CHOICE_LIMIT = Plutonium::UI::Form::Components::ResourceSelect::DEFAULT_CHOICE_LIMIT
9
12
 
10
13
  def view_template
11
14
  div(class: "flex items-center space-x-1") do
@@ -19,50 +22,102 @@ module Plutonium
19
22
  delegate :association_reflection, to: :field
20
23
 
21
24
  def render_add_button
22
- return if @add_action == false || add_url.nil?
25
+ return if @add_action == false
26
+
27
+ url, turbo_frame = add_url_and_frame
28
+ return unless url
29
+
30
+ # When the parent form is already inside a modal, route the
31
+ # "+" to the secondary frame so the stacked dialog opens on
32
+ # top of the original form rather than replacing it. The
33
+ # crud controller mirrors this on success — closing the
34
+ # secondary modal and reloading the primary so the
35
+ # association select picks up the new record.
36
+ if turbo_frame == Plutonium::REMOTE_MODAL_FRAME && in_modal?
37
+ turbo_frame = Plutonium::REMOTE_MODAL_SECONDARY_FRAME
38
+ end
39
+
40
+ attrs = {
41
+ href: url,
42
+ 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"
43
+ }
44
+ attrs[:data] = {turbo_frame: turbo_frame} if turbo_frame
23
45
 
24
- a(
25
- href: add_url,
26
- class: "bg-[var(--pu-surface-alt)] hover:bg-[var(--pu-border)] border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] px-4 py-3 focus:ring-2 focus:ring-[var(--pu-border)] focus:outline-none text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] transition-colors"
27
- ) do
28
- render Phlex::TablerIcons::Plus.new(class: "w-6 h-6")
46
+ a(**attrs) do
47
+ render Phlex::TablerIcons::Plus.new(class: "w-4 h-4")
29
48
  end
30
49
  end
31
50
 
32
- def add_url
33
- @add_url ||= begin
34
- return unless @skip_authorization || allowed_to?(:create?, association_reflection.klass)
51
+ # Resolves the destination for the inline "+" button alongside
52
+ # the association select. We go through the target resource's
53
+ # `:new` action (rather than building a URL by hand) so the
54
+ # button inherits whatever modal/slideover frame the target
55
+ # resource is configured for — same path table/grid use for
56
+ # their own "New" button. A custom string `add_action:` skips
57
+ # the frame lookup since we can't infer the target's modal
58
+ # mode from an arbitrary URL.
59
+ def add_url_and_frame
60
+ klass = association_reflection.klass
61
+
62
+ if @add_action.is_a?(String)
63
+ return [with_return_to(@add_action), nil] if @skip_authorization || allowed_to?(:create?, klass)
64
+ return
65
+ end
35
66
 
36
- url = @add_action || (registered_resources.include?(association_reflection.klass) && resource_url_for(association_reflection.klass, action: :new, parent: nil))
37
- return unless url
67
+ return unless registered_resources.include?(klass)
68
+ action = resource_definition(klass).defined_actions[:new]
69
+ return unless action
70
+ return unless @skip_authorization || action.permitted_by?(policy_for(record: klass))
38
71
 
39
- uri = URI(url)
40
- uri.query = URI.encode_www_form({return_to: request.original_url})
41
- uri.to_s
42
- end
72
+ url = route_options_to_url(action.route_options, klass)
73
+ [with_return_to(url), action.turbo_frame]
74
+ end
75
+
76
+ def with_return_to(url)
77
+ uri = URI(url)
78
+ params = Rack::Utils.parse_nested_query(uri.query)
79
+ params["return_to"] = request.original_url
80
+ uri.query = params.to_query
81
+ uri.to_s
43
82
  end
44
83
 
45
84
  def choices
46
85
  @choices ||= begin
47
- collection = if @raw_choices
48
- @raw_choices
49
- elsif @skip_authorization
50
- choices_from_association(association_reflection.klass)
51
- else
52
- authorized_resource_scope(association_reflection.klass, relation: choices_from_association(association_reflection.klass))
53
- end
86
+ collection = @raw_choices || authorized_relation
87
+ collection = collection.limit(@choice_limit) if @choice_limit && collection.respond_to?(:limit)
54
88
  build_choice_mapper(collection)
55
89
  end
56
90
  end
57
91
 
92
+ # Builds the authorized association relation. Shared by `choices`
93
+ # (which then applies `choice_limit`) and `normalize_simple_input`
94
+ # (which validates against the full set so typeahead picks beyond
95
+ # the rendered subset still survive submit).
96
+ def authorized_relation
97
+ klass = association_reflection.klass
98
+ relation = choices_from_association(klass)
99
+ return relation if @skip_authorization
100
+ authorized_resource_scope(klass, relation: relation)
101
+ end
102
+
58
103
  def build_attributes
59
104
  build_association_attributes
60
105
  super
106
+ # Stash; the URL helper needs view_context which only exists
107
+ # once we're rendering.
108
+ @typeahead_option = attributes.delete(:typeahead)
109
+ end
110
+
111
+ def before_template
112
+ super
113
+ configure_typeahead_attributes!(@typeahead_option)
61
114
  end
62
115
 
63
116
  def build_association_attributes
64
117
  @skip_authorization = attributes.delete(:skip_authorization)
65
118
  @add_action = attributes.delete(:add_action)
119
+ @choice_limit = attributes.fetch(:choice_limit) { DEFAULT_CHOICE_LIMIT }
120
+ attributes.delete(:choice_limit)
66
121
 
67
122
  attributes.fetch(:value_method) { attributes[:value_method] = :to_signed_global_id }
68
123
 
@@ -74,6 +129,20 @@ module Plutonium
74
129
  end
75
130
  end
76
131
 
132
+ private
133
+
134
+ # Polymorphic reflections raise NameError on #klass — they
135
+ # have no single target class to search, so opt out.
136
+ def typeahead_target_class
137
+ return nil unless association_reflection
138
+ return nil if association_reflection.respond_to?(:polymorphic?) && association_reflection.polymorphic?
139
+ association_reflection.klass
140
+ end
141
+
142
+ def typeahead_kind_and_name(_typeahead_option)
143
+ [:input, association_reflection.name]
144
+ end
145
+
77
146
  def build_singluar_association_attributes
78
147
  attributes.fetch(:input_param) { attributes[:input_param] = :"#{association_reflection.name}_sgid" }
79
148
  end
@@ -83,9 +152,21 @@ module Plutonium
83
152
  attributes[:multiple] = true
84
153
  end
85
154
 
155
+ # Validates a submitted SGID against the authorized association scope
156
+ # (not against `choices`, which is capped at `choice_limit` and may
157
+ # not include records reachable via typeahead). For explicit
158
+ # `@raw_choices`, fall back to membership in the rendered list.
86
159
  def normalize_simple_input(input_value)
87
- @signed_global_ids ||= choices.values.map { |choice| SignedGlobalID.parse(choice) }
88
- ([SignedGlobalID.parse(input_value.presence)].compact & @signed_global_ids)[0]
160
+ sgid = SignedGlobalID.parse(input_value.presence)
161
+ return nil unless sgid
162
+
163
+ if @raw_choices
164
+ @signed_global_ids ||= choices.values.map { |choice| SignedGlobalID.parse(choice) }
165
+ return @signed_global_ids.include?(sgid) ? sgid : nil
166
+ end
167
+
168
+ return nil unless sgid.model_class <= association_reflection.klass
169
+ authorized_relation.exists?(id: sgid.model_id) ? sgid : nil
89
170
  end
90
171
 
91
172
  def selected?(option)
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ class StickyFooter < Plutonium::UI::Component::Base
8
+ def view_template(&block)
9
+ div(class: "fixed bottom-0 left-0 right-0 lg:left-14 z-20 " \
10
+ "h-14 bg-[var(--pu-surface)] border-t border-[var(--pu-border)] " \
11
+ "px-6 flex items-center justify-end gap-2", &block)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -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
@@ -18,10 +18,23 @@ module Plutonium
18
18
  end
19
19
 
20
20
  def form_template
21
- render_fields
21
+ if in_modal?
22
+ # In modal: form is the flex container that fills the modal
23
+ # body. Fields region scrolls; action strip sits flush at the
24
+ # bottom edge of the modal.
25
+ div(class: "flex-1 min-h-0 overflow-y-auto px-6 py-5") do
26
+ render_fields
27
+ end
28
+ else
29
+ render_fields
30
+ end
22
31
  render_actions
23
32
  end
24
33
 
34
+ def form_class
35
+ in_modal? ? "flex-1 flex flex-col min-h-0" : super
36
+ end
37
+
25
38
  private
26
39
 
27
40
  def render_fields
@@ -33,18 +46,39 @@ module Plutonium
33
46
  end
34
47
 
35
48
  def render_actions
36
- input name: "return_to", value: request.params[:return_to], type: :hidden, hidden: true
37
-
38
- actions_wrapper {
39
- render_submit_and_continue_button if show_submit_and_continue?
40
-
41
- render submit_button
42
- }
49
+ # capture-url controller sets this element's value to
50
+ # window.location.href on connect, so URL fragments (#tab-id)
51
+ # survive the redirect after submit (the server never sees them).
52
+ input name: "return_to",
53
+ value: request.params[:return_to] || request.original_url,
54
+ type: :hidden,
55
+ hidden: true,
56
+ data: {controller: "capture-url"}
57
+
58
+ if in_modal?
59
+ div(class: "shrink-0 px-6 py-3 " \
60
+ "bg-[var(--pu-surface)] border-t border-[var(--pu-border)] " \
61
+ "flex items-center justify-end gap-2") do
62
+ render_submit_and_continue_button if show_submit_and_continue?
63
+ render submit_button
64
+ end
65
+ else
66
+ render Plutonium::UI::Form::Components::StickyFooter.new do
67
+ render_submit_and_continue_button if show_submit_and_continue?
68
+ render submit_button
69
+ end
70
+ end
43
71
  end
44
72
 
45
73
  def show_submit_and_continue?
46
74
  return false unless object.respond_to?(:new_record?)
47
75
 
76
+ # Continue / add-another lands on the form's standalone URL —
77
+ # which breaks the experience when the form is inside a frame
78
+ # (modal or association tab) since the redirect can't keep the
79
+ # user in that frame context.
80
+ return false if current_turbo_frame.present?
81
+
48
82
  # Check explicit configuration first
49
83
  configured = resource_definition.submit_and_continue
50
84
  return configured unless configured.nil?
@@ -60,7 +94,7 @@ module Plutonium
60
94
  type: :submit,
61
95
  name: "return_to",
62
96
  value: request.url,
63
- class: "px-4 py-2 bg-secondary-600 text-white rounded-md hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-secondary-500"
97
+ class: "pu-btn pu-btn-md pu-btn-outline"
64
98
  ) { label }
65
99
  end
66
100
 
@@ -116,7 +150,8 @@ module Plutonium
116
150
  end
117
151
  end
118
152
 
119
- field_options = field_options.except(:as, :condition)
153
+ # Keep `:as` so the Builder can detect hidden fields via `options[:as]`.
154
+ field_options = field_options.except(:condition)
120
155
 
121
156
  condition = input_options[:condition] || field_options[:condition]
122
157
  conditionally_hidden = condition && !instance_exec(&condition)
@@ -49,7 +49,7 @@ module Plutonium
49
49
  hint: "pu-hint whitespace-pre",
50
50
 
51
51
  # Error themes
52
- error: "pu-error",
52
+ error: "text-xs text-danger-600 mt-1",
53
53
 
54
54
  # Button themes
55
55
  button: "pu-btn pu-btn-md pu-btn-primary",
@@ -10,12 +10,14 @@ module Plutonium
10
10
 
11
11
  def view_template
12
12
  button(
13
+ type: "button",
13
14
  title: @label,
15
+ aria: {label: @label},
14
16
  style: "display: none",
15
- class: "text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] transition-colors",
17
+ class: "inline-flex items-center justify-center w-7 h-7 rounded text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors",
16
18
  **@attributes
17
19
  ) {
18
- render @icon.new(class: "w-6 h-6")
20
+ render @icon.new(class: "w-4 h-4")
19
21
  }
20
22
  end
21
23
  end
@@ -31,11 +33,12 @@ module Plutonium
31
33
  def view_template
32
34
  a(
33
35
  title: @label,
34
- class: "text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] transition-colors",
36
+ aria: {label: @label},
35
37
  href: @href,
38
+ class: "inline-flex items-center justify-center w-7 h-7 rounded text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors",
36
39
  **@attributes
37
40
  ) {
38
- render @icon.new(class: "w-6 h-6")
41
+ render @icon.new(class: "w-4 h-4")
39
42
  }
40
43
  end
41
44
  end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Grid
6
+ # Renders a single record as a card built from semantic slots
7
+ # (image / header / subheader / body / meta / footer) declared via
8
+ # `grid_fields` on the resource definition. Each slot is optional;
9
+ # `header` falls back to `record.to_label` when undeclared.
10
+ class Card < Plutonium::UI::Component::Base
11
+ attr_reader :record, :resource_definition, :resource_fields
12
+
13
+ def initialize(record, resource_definition:, resource_fields: nil)
14
+ @record = record
15
+ @resource_definition = resource_definition
16
+ @resource_fields = resource_fields
17
+ end
18
+
19
+ def view_template
20
+ article(
21
+ class: card_class,
22
+ data: {controller: "row-click", action: "click->row-click#click"}
23
+ ) do
24
+ render_show_link if can_show?
25
+ render_actions_dropdown
26
+ case resource_definition.defined_grid_layout
27
+ when :media then render_media_layout
28
+ else render_compact_layout
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def slots = resource_definition.defined_grid_fields
36
+
37
+ # ---------------------------------------------------------------
38
+ # Layout shells
39
+ # ---------------------------------------------------------------
40
+
41
+ def render_compact_layout
42
+ div(class: "flex items-start gap-3 p-4") do
43
+ render_image_slot(size: :sm) if slots[:image]
44
+ div(class: "min-w-0 flex-1 flex flex-col gap-1") do
45
+ render_header_slot
46
+ render_subheader_slot if slots[:subheader]
47
+ render_body_slot if slots[:body]
48
+ render_meta_slot if slots[:meta]
49
+ render_footer_slot if footer_field
50
+ end
51
+ end
52
+ end
53
+
54
+ def render_media_layout
55
+ render_image_slot(size: :cover) if slots[:image]
56
+ div(class: "p-4 flex flex-col gap-1") do
57
+ render_header_slot
58
+ render_subheader_slot if slots[:subheader]
59
+ render_body_slot if slots[:body]
60
+ render_meta_slot if slots[:meta]
61
+ render_footer_slot if footer_field
62
+ end
63
+ end
64
+
65
+ # Footer falls back to `:created_at` when the slot is unset and
66
+ # the record has a created_at column. Gives cards a sensible
67
+ # second line without forcing every grid_fields call to repeat it.
68
+ def footer_field
69
+ slots[:footer] || (record.respond_to?(:created_at) ? :created_at : nil)
70
+ end
71
+
72
+ # ---------------------------------------------------------------
73
+ # Slot renderers
74
+ # ---------------------------------------------------------------
75
+
76
+ def render_image_slot(size:)
77
+ value = field_value(slots[:image])
78
+ return unless value
79
+ src = image_src_for(value)
80
+ return unless src
81
+
82
+ if size == :cover
83
+ div(class: "w-full aspect-video bg-[var(--pu-surface-alt)] overflow-hidden") do
84
+ img(src: src, alt: header_text.to_s, class: "w-full h-full object-cover")
85
+ end
86
+ else
87
+ img(
88
+ src: src,
89
+ alt: header_text.to_s,
90
+ class: "w-12 h-12 rounded-full object-cover bg-[var(--pu-surface-alt)] shrink-0"
91
+ )
92
+ end
93
+ end
94
+
95
+ def render_header_slot
96
+ h3(class: "text-sm font-semibold text-[var(--pu-text)] truncate") do
97
+ plain header_text
98
+ end
99
+ end
100
+
101
+ def render_subheader_slot
102
+ value = field_value(slots[:subheader])
103
+ return if value.blank?
104
+ p(class: "text-xs text-[var(--pu-text-muted)] truncate") { plain helpers.display_name_of(value) }
105
+ end
106
+
107
+ def render_body_slot
108
+ value = field_value(slots[:body])
109
+ return if value.blank?
110
+ p(class: "text-sm text-[var(--pu-text)] line-clamp-3") { plain helpers.display_name_of(value) }
111
+ end
112
+
113
+ def render_meta_slot
114
+ fields = Array(slots[:meta])
115
+ values = fields.map { |f| field_value(f) }.reject(&:blank?)
116
+ return if values.empty?
117
+
118
+ div(class: "flex flex-wrap items-center gap-1.5 mt-1") do
119
+ values.each do |v|
120
+ span(class: "inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium " \
121
+ "bg-[var(--pu-surface-alt)] text-[var(--pu-text-muted)]") do
122
+ plain helpers.display_name_of(v)
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ def render_footer_slot
129
+ value = field_value(footer_field)
130
+ return if value.blank?
131
+ p(class: "text-xs text-[var(--pu-text-subtle)] mt-1") do
132
+ if value.respond_to?(:strftime)
133
+ # display_datetime_value returns HTML-safe <time> markup
134
+ # rendered by the timeago Stimulus controller.
135
+ raw safe(helpers.display_datetime_value(value))
136
+ else
137
+ plain helpers.display_name_of(value)
138
+ end
139
+ end
140
+ end
141
+
142
+ # ---------------------------------------------------------------
143
+ # Card chrome — selection, actions, show
144
+ # ---------------------------------------------------------------
145
+
146
+ def render_actions_dropdown
147
+ # Cards have limited surface area, so all collection-record
148
+ # actions (including primary ones like Edit) live in the
149
+ # dropdown rather than splitting between buttons and a menu
150
+ # like the table view does.
151
+ actions = row_actions.reject { |a| a.name == :show }
152
+ return if actions.empty?
153
+ div(class: "absolute top-2 right-2 z-10") do
154
+ RowActionsDropdown(actions: actions, record:)
155
+ end
156
+ end
157
+
158
+ # Hidden link the `row-click` controller delegates to when the
159
+ # user clicks anywhere on the card body. Mirrors how the show
160
+ # action button works in the Table view.
161
+ def render_show_link
162
+ show = resource_definition.defined_actions[:show]
163
+ url = route_options_to_url(show.route_options, record)
164
+ a(
165
+ href: url,
166
+ data: {row_click_target: "show", turbo_frame: show.turbo_frame},
167
+ class: "sr-only",
168
+ tabindex: "-1",
169
+ "aria-label": "Open #{header_text}"
170
+ ) { plain "Open" }
171
+ end
172
+
173
+ # ---------------------------------------------------------------
174
+ # Helpers
175
+ # ---------------------------------------------------------------
176
+
177
+ def header_text
178
+ @header_text ||= helpers.display_name_of(field_value(slots[:header]) || record)
179
+ end
180
+
181
+ def field_value(name)
182
+ return nil unless name
183
+ # Skip fields the user's policy doesn't permit. nil collapses
184
+ # the slot in render_*_slot guards above.
185
+ return nil if resource_fields && !resource_fields.include?(name.to_sym)
186
+ unless record.respond_to?(name)
187
+ raise ArgumentError,
188
+ "grid_fields slot points at `:#{name}` but " \
189
+ "#{record.class.name} doesn't respond to it. " \
190
+ "Define the method on the model or remove the slot."
191
+ end
192
+ record.public_send(name)
193
+ end
194
+
195
+ # Resolves a slot value to an image URL. Supports:
196
+ # - ActiveStorage attachments (`record.avatar` -> Attached::One/Many)
197
+ # - Shrine uploaders (`record.avatar` -> UploadedFile, responds to :url)
198
+ # - Plain URL strings ("https://..." or "/uploads/...")
199
+ def image_src_for(value)
200
+ return nil if value.nil?
201
+ if value.respond_to?(:attached?)
202
+ value.attached? ? helpers.url_for(value) : nil
203
+ elsif value.respond_to?(:url)
204
+ value.url
205
+ elsif value.is_a?(String) && value.start_with?("http", "/")
206
+ value
207
+ end
208
+ rescue ArgumentError, URI::InvalidURIError
209
+ nil
210
+ end
211
+
212
+ def row_actions
213
+ @row_actions ||= resource_definition.defined_actions.values.select { |a|
214
+ a.collection_record_action? && a.permitted_by?(record_policy)
215
+ }
216
+ end
217
+
218
+ def can_show?
219
+ resource_definition.defined_actions[:show]&.permitted_by?(record_policy)
220
+ end
221
+
222
+ def record_policy
223
+ @record_policy ||= policy_for(record:)
224
+ end
225
+
226
+ def card_class
227
+ tokens(
228
+ "pu-card relative overflow-hidden transition-shadow",
229
+ -> { can_show? } => "cursor-pointer hover:shadow-md focus-within:ring-2 focus-within:ring-primary-500"
230
+ )
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end