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
@@ -78,6 +78,8 @@ module Plutonium
78
78
 
79
79
  def BuildTableScopesBar(...) = Plutonium::UI::Table::Components::ScopesBar.new(...)
80
80
 
81
+ def BuildTableScopesPills(...) = Plutonium::UI::Table::Components::ScopesPills.new(...)
82
+
81
83
  def BuildTableInfo(...) = Plutonium::UI::Table::Components::PagyInfo.new(...)
82
84
 
83
85
  def BuildTablePagination(...) = Plutonium::UI::Table::Components::PagyPagination.new(...)
@@ -86,7 +88,17 @@ module Plutonium
86
88
 
87
89
  def BuildBulkActionsToolbar(...) = Plutonium::UI::Table::Components::BulkActionsToolbar.new(...)
88
90
 
91
+ def BuildTableToolbar(...) = Plutonium::UI::Table::Components::Toolbar.new(...)
92
+
93
+ def BuildTableFilterPills(...) = Plutonium::UI::Table::Components::FilterPills.new(...)
94
+
95
+ def BuildTableViewSwitcher(...) = Plutonium::UI::Table::Components::ViewSwitcher.new(...)
96
+
89
97
  def BuildColorModeSelector(...) = Plutonium::UI::ColorModeSelector.new(...)
98
+
99
+ def BuildModalCentered(...) = Plutonium::UI::Modal::Centered.new(...)
100
+
101
+ def BuildModalSlideover(...) = Plutonium::UI::Modal::Slideover.new(...)
90
102
  end
91
103
  end
92
104
  end
@@ -40,10 +40,14 @@ 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?,
47
51
  :current_interactive_action,
48
52
  :current_engine,
49
53
  :policy_for,
@@ -46,7 +46,9 @@ module Plutonium
46
46
 
47
47
  def fields_wrapper(&)
48
48
  div(class: themed(:fields_wrapper)) {
49
- yield
49
+ div(class: themed(:fields_inner)) {
50
+ yield
51
+ }
50
52
  }
51
53
  end
52
54
  end
@@ -13,56 +13,93 @@ module Plutonium
13
13
  @resource_definition = resource_definition
14
14
  end
15
15
 
16
+ # Metadata fields the user is permitted to see — intersection of
17
+ # the definition's declared metadata with the policy-filtered
18
+ # `resource_fields`. Computed lazily so we don't run the
19
+ # intersection when the resource doesn't declare any metadata.
20
+ def metadata_fields
21
+ @metadata_fields ||= resource_definition.defined_metadata_fields & resource_fields
22
+ end
23
+
16
24
  def display_template
17
- render_fields
18
- render_associations if present_associations?
25
+ if associations_present?
26
+ render_tablist_with_details
27
+ else
28
+ render_fields
29
+ end
19
30
  end
20
31
 
21
32
  private
22
33
 
34
+ def associations_present?
35
+ present_associations? && resource_associations.present?
36
+ end
37
+
23
38
  def render_fields
39
+ if metadata_fields.any?
40
+ div(class: "grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_320px] gap-6 items-start") do
41
+ div { render_main_field_card }
42
+ aside { render_metadata_panel }
43
+ end
44
+ else
45
+ render_main_field_card
46
+ end
47
+ end
48
+
49
+ def render_main_field_card
24
50
  Block do
25
51
  fields_wrapper do
26
- resource_fields.each do |name|
52
+ # Skip fields claimed by the metadata panel — rendering
53
+ # them in both places duplicates information.
54
+ (resource_fields - metadata_fields).each do |name|
27
55
  render_resource_field name
28
56
  end
29
57
  end
30
58
  end
31
59
  end
32
60
 
33
- def render_associations
34
- return unless resource_associations.present?
61
+ # Renders the declared metadata fields as a vertical stack beside
62
+ # the main field card. Reuses render_resource_field (same path
63
+ # the main details use) so labels/values match in style; the
64
+ # only difference from the main card is the wrapper — a single
65
+ # column of fields instead of the form's multi-column grid.
66
+ def render_metadata_panel
67
+ Block do
68
+ div(class: "pu-card-body flex flex-col gap-6") do
69
+ metadata_fields.each { |name| render_resource_field(name) }
70
+ end
71
+ end
72
+ end
35
73
 
74
+ def render_tablist_with_details
36
75
  tablist = BuildTabList()
37
76
 
77
+ # Build an inner display component for the Details tab.
78
+ # It must be a standalone Phlex component so that TabList can call
79
+ # `render(details_display)` from within its own context. Phlex propagates
80
+ # @_state through render calls, so the inner component writes to the same
81
+ # buffer as the outer Resource display even though self changes.
82
+ details_display = build_details_display
83
+
84
+ tablist.with_tab(
85
+ identifier: "details",
86
+ title: -> { plain "Details" }
87
+ ) do
88
+ render details_display
89
+ end
90
+
38
91
  resource_associations.each do |name|
39
92
  reflection = object.class.reflect_on_association name
40
-
41
- if !reflection
42
- raise ArgumentError,
43
- "unknown association #{object.class}##{name} defined in #permitted_associations"
44
- elsif !registered_resources.include?(reflection.klass)
45
- raise ArgumentError,
46
- "#{object.class}##{name} defined in #permitted_associations, but #{reflection.klass} is not a registered resource"
47
- end
93
+ raise_unknown_association(name) unless reflection
94
+ raise_unregistered_association(name, reflection) unless registered_resources.include?(reflection.klass)
48
95
 
49
96
  title = object.class.human_attribute_name(name)
50
- src = case reflection.macro
51
- when :belongs_to
52
- associated = object.public_send name
53
- resource_url_for(associated, parent: nil) if associated
54
- when :has_one
55
- associated = object.public_send name
56
- resource_url_for(associated, parent: object, association: name)
57
- when :has_many
58
- resource_url_for(reflection.klass, parent: object, association: name)
59
- end
60
-
97
+ src = association_src(name, reflection)
61
98
  next unless src
62
99
 
63
100
  tablist.with_tab(
64
101
  identifier: title.parameterize,
65
- title: -> { h5(class: "text-2xl font-bold tracking-tight text-[var(--pu-text)]") { title } }
102
+ title: -> { plain title }
66
103
  ) do
67
104
  FrameNavigatorPanel(title: "", src:, panel_id: "association-panel-#{title.parameterize}")
68
105
  end
@@ -71,6 +108,53 @@ module Plutonium
71
108
  render tablist
72
109
  end
73
110
 
111
+ # Builds a standalone Phlex component whose sole job is to render the
112
+ # resource fields. Having a distinct component lets TabList call
113
+ # `render(details_display)` so that Phlex propagates its @_state correctly,
114
+ # while avoiding the `instance_exec` context-switch problem that would
115
+ # occur if we put `render_fields` directly inside the `with_tab` block.
116
+ #
117
+ # The anonymous subclass overrides `view_template` to skip the outer
118
+ # `display_wrapper` div (which would duplicate the dom id already emitted
119
+ # by the parent Resource display) and renders just the fields content.
120
+ def build_details_display
121
+ resource = self
122
+
123
+ klass = Class.new(self.class) do
124
+ define_method(:view_template) do
125
+ resource.send(:render_fields)
126
+ end
127
+ end
128
+
129
+ klass.new(
130
+ object,
131
+ resource_fields: resource_fields,
132
+ resource_associations: [],
133
+ resource_definition: resource_definition
134
+ )
135
+ end
136
+
137
+ def association_src(name, reflection)
138
+ case reflection.macro
139
+ when :belongs_to
140
+ associated = object.public_send name
141
+ resource_url_for(associated, parent: nil) if associated
142
+ when :has_one
143
+ associated = object.public_send name
144
+ resource_url_for(associated, parent: object, association: name)
145
+ when :has_many
146
+ resource_url_for(reflection.klass, parent: object, association: name)
147
+ end
148
+ end
149
+
150
+ def raise_unknown_association(name)
151
+ raise ArgumentError, "unknown association #{object.class}##{name} defined in #permitted_associations"
152
+ end
153
+
154
+ def raise_unregistered_association(name, reflection)
155
+ raise ArgumentError, "#{object.class}##{name} defined in #permitted_associations, but #{reflection.klass} is not a registered resource"
156
+ end
157
+
74
158
  def render_resource_field(name)
75
159
  when_permitted(name) do
76
160
  # field :name, as: :string
@@ -8,7 +8,8 @@ module Plutonium
8
8
  super.merge({
9
9
  base: "",
10
10
  value_wrapper: "max-h-[300px] overflow-y-auto",
11
- fields_wrapper: "p-8 grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-x-8 gap-y-8 grid-flow-row-dense",
11
+ fields_wrapper: "pu-card",
12
+ fields_inner: "pu-card-body grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-x-8 gap-y-6 grid-flow-row-dense",
12
13
 
13
14
  # Labels and descriptions
14
15
  label: "text-sm font-semibold uppercase tracking-wide text-[var(--pu-text-muted)] mb-2",
@@ -1,29 +1,23 @@
1
1
  module Plutonium
2
2
  module UI
3
3
  module DynaFrame
4
+ # Conditionally wraps its content in a turbo-frame matching the inbound
5
+ # request's `Turbo-Frame` header. In frame mode adds the flash partial
6
+ # so toast/alert messages still surface inside frames; in non-frame
7
+ # mode renders the block as-is.
4
8
  class Content < Plutonium::UI::Component::Base
5
9
  include Phlex::Rails::Helpers::TurboFrameTag
6
10
 
7
- def initialize(content = nil)
8
- @content = content
9
- end
10
-
11
- def view_template
11
+ def view_template(&block)
12
12
  if current_turbo_frame.present?
13
- # Frame request: render only the turbo-frame with content
14
13
  turbo_frame_tag(current_turbo_frame) do
15
14
  render partial("flash")
16
- @content&.call
15
+ yield if block_given?
17
16
  end
18
- else
19
- # Regular request: yield self so caller can call frame.render_content
20
- yield(self)
17
+ elsif block_given?
18
+ yield
21
19
  end
22
20
  end
23
-
24
- def render_content
25
- @content&.call
26
- end
27
21
  end
28
22
  end
29
23
  end
@@ -8,7 +8,7 @@ module Plutonium
8
8
  end
9
9
 
10
10
  def view_template
11
- div(class: "pu-card") do
11
+ div(class: "pu-card mt-4") do
12
12
  div(class: "pu-empty-state") do
13
13
  p(class: "pu-empty-state-description") { message }
14
14
  yield if block_given?
@@ -10,6 +10,28 @@ module Plutonium
10
10
  include Phlexi::Field::Common::Tokens
11
11
  include Plutonium::UI::Form::Options::InferredTypes
12
12
 
13
+ # Consume `:as` here so it doesn't land in Phlexi's `@options` —
14
+ # `:as` is a Plutonium-internal concept (it picks the tag method),
15
+ # not a Phlexi field option.
16
+ def initialize(*args, as: nil, **kwargs, &block)
17
+ @as = as
18
+ super(*args, **kwargs, &block)
19
+ end
20
+
21
+ attr_reader :as
22
+
23
+ def hidden?
24
+ as.to_s == "hidden"
25
+ end
26
+
27
+ # Hidden fields (`form.field(name, as: :hidden)`) skip the label /
28
+ # hint / error chrome and render inside a `<div hidden>` so they're
29
+ # also excluded from CSS Grid / Flex layout.
30
+ def wrapped(**, &)
31
+ return Plutonium::UI::Form::Components::HiddenWrapper.new(self, &) if hidden?
32
+ super
33
+ end
34
+
13
35
  def textarea_tag(**attributes, &)
14
36
  attributes[:data_controller] = tokens(attributes[:data_controller], "textarea-autogrow")
15
37
  super
@@ -44,8 +66,18 @@ module Plutonium
44
66
  create_component(Components::KeyValueStore, :key_value_store, **, &)
45
67
  end
46
68
 
69
+ def json_input_tag(**, &)
70
+ create_component(Components::Json, :json, **, &)
71
+ end
72
+
47
73
  def resource_select_tag(**attributes, &)
48
- create_component(Components::ResourceSelect, :select, **attributes, &)
74
+ attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select")
75
+ # class!: "" clears the underlying <select>'s themed classes
76
+ # (pu-input etc.) — the visible element is slim-select's
77
+ # generated .ss-main, so leaving Tailwind input chrome on the
78
+ # native select can leak into chip layout (e.g. forcing
79
+ # flex-direction: column or w-full on multi-mode chips).
80
+ create_component(Components::ResourceSelect, :select, class!: "", **attributes, &)
49
81
  end
50
82
 
51
83
  def secure_association_tag(**attributes, &)
@@ -79,8 +111,8 @@ module Plutonium
79
111
  alias_method :date_tag, :flatpickr_tag
80
112
  alias_method :time_tag, :flatpickr_tag
81
113
  alias_method :rich_text_tag, :markdown_tag
82
- alias_method :json_tag, :textarea_tag
83
- alias_method :jsonb_tag, :textarea_tag
114
+ alias_method :json_tag, :json_input_tag
115
+ alias_method :jsonb_tag, :json_input_tag
84
116
  alias_method :hstore_tag, :key_value_store_tag
85
117
  alias_method :key_value_tag, :key_value_store_tag
86
118
  alias_method :association_tag, :secure_association_tag
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # Wrapper for fields configured as `as: :hidden`. Emits a hidden div
8
+ # containing only the input — no label, no hint, no error chrome.
9
+ # `hidden: true` (HTML5) sets `display: none` so the wrapper is
10
+ # excluded from CSS Grid / Flex layout, not just visually hidden.
11
+ class HiddenWrapper < Phlexi::Form::Components::Base
12
+ def view_template(&block)
13
+ div(hidden: true) do
14
+ yield(field) if block
15
+ end
16
+ end
17
+
18
+ # No id needed for a layout-suppressed wrapper.
19
+ def build_attributes
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ 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
@@ -6,24 +6,156 @@ module Plutonium
6
6
  module Components
7
7
  # Select for choosing a resource record
8
8
  class ResourceSelect < Phlexi::Form::Components::Select
9
+ include Plutonium::UI::Component::Methods
10
+ include Plutonium::UI::Form::Concerns::TypeaheadAttributes
11
+
12
+ # Cap on the number of records the dropdown materialises. Keeps
13
+ # very large association tables from rendering thousands of
14
+ # options into the page; consumers needing more should pair this
15
+ # with a typeahead control later.
16
+ DEFAULT_CHOICE_LIMIT = 100
17
+
9
18
  protected
10
19
 
11
20
  def choices
12
21
  @choices ||= begin
13
- collection = @raw_choices || @association_class&.all || []
22
+ collection = if @raw_choices
23
+ @raw_choices
24
+ elsif @association_class.nil?
25
+ []
26
+ else
27
+ authorized_relation(limit: @choice_limit)
28
+ end
14
29
  build_choice_mapper(collection)
15
30
  end
16
31
  end
17
32
 
33
+ # Builds the authorized relation for the association class, optionally
34
+ # capped at `limit`. Shared by `choices` (with the limit) and
35
+ # `normalize_simple_input` (without — so typeahead picks beyond the
36
+ # rendered subset still validate).
37
+ def authorized_relation(limit: nil)
38
+ relation = @association_class.all
39
+ relation = relation.limit(limit) if limit && relation.respond_to?(:limit)
40
+ return relation if @skip_authorization
41
+ authorized_resource_scope(@association_class, relation: relation)
42
+ end
43
+
18
44
  def build_attributes
45
+ # Defaults must land BEFORE super — AcceptsChoices.build_attributes
46
+ # consumes :value_method / :label_method off `attributes` into
47
+ # its own ivars, so anything we set after super has no effect.
48
+ attributes[:value_method] ||= :to_signed_global_id
49
+ attributes[:label_method] ||= :to_label
50
+
19
51
  super
52
+
20
53
  @association_class = attributes.delete(:association_class)
54
+ @skip_authorization = attributes.delete(:skip_authorization)
55
+ @choice_limit = attributes.fetch(:choice_limit) { DEFAULT_CHOICE_LIMIT }
56
+ attributes.delete(:choice_limit)
57
+ # Stash the typeahead option; the URL helper needs view_context
58
+ # which only exists once we're rendering.
59
+ @typeahead_option = attributes.delete(:typeahead)
60
+ end
61
+
62
+ # Phlex hook fires right before view_template runs and view_context
63
+ # is available, so this is where we can resolve the typeahead URL
64
+ # and inject the data attr.
65
+ def before_template
66
+ super
67
+ configure_typeahead_attributes!(@typeahead_option)
68
+ end
69
+
70
+ # SGIDs include a timestamp + signature, so the SGID in the URL
71
+ # (generated when the user submitted) won't string-equal the
72
+ # SGID we just generated for the same record. Compare by the
73
+ # decoded model id instead, falling back to raw string equality
74
+ # for non-SGID values (legacy URLs / explicit raw choices).
75
+ def selected?(option)
76
+ if attributes[:multiple]
77
+ Array(field.value).any? { |v| same_record?(v, option) }
78
+ else
79
+ same_record?(field.value, option)
80
+ end
81
+ end
82
+
83
+ # AcceptsChoices.normalize_simple_input rejects any submitted
84
+ # value that doesn't string-match an option's value. With SGIDs
85
+ # the URL value (signed/timestamped at submit time) never
86
+ # string-equals a freshly generated option SGID for the same
87
+ # record, so the value gets silently dropped — no WHERE clause
88
+ # is built and the filter behaves as if it weren't applied.
89
+ #
90
+ # For SGID values backed by an association class, validate against
91
+ # the unbounded authorized relation rather than the rendered
92
+ # `choices` (which is capped at `choice_limit` and may not include
93
+ # records reachable via typeahead). For raw values / explicit
94
+ # `@raw_choices`, fall back to record-equality against the rendered
95
+ # options.
96
+ def normalize_simple_input(input_value)
97
+ return nil if input_value.blank?
98
+
99
+ sgid = SignedGlobalID.parse(input_value)
100
+ if sgid && @association_class && !@raw_choices
101
+ return nil unless sgid.model_class <= @association_class
102
+ return authorized_relation.exists?(id: sgid.model_id) ? input_value : nil
103
+ end
104
+
105
+ choices.values.find { |opt| same_record?(input_value, opt) } && input_value
106
+ end
107
+
108
+ # Two values point at the same record when both decode to the
109
+ # same SGID (class + id). For explicit non-SGID `@raw_choices`,
110
+ # both sides are plain strings and string-equality is the only
111
+ # sensible answer. Mixed-format (one SGID, one raw) returns
112
+ # false — no cross-format guessing.
113
+ def same_record?(a, b)
114
+ return false if a.blank? || b.blank?
115
+
116
+ a_pair = decode_class_and_id(a)
117
+ b_pair = decode_class_and_id(b)
118
+ return a_pair == b_pair if a_pair && b_pair
119
+ return false if a_pair || b_pair
120
+
121
+ a.to_s == b.to_s
122
+ end
123
+
124
+ def decode_class_and_id(value)
125
+ gid = SignedGlobalID.parse(value)
126
+ gid && [gid.model_class.name, gid.model_id]
127
+ rescue
128
+ nil
21
129
  end
22
130
 
23
131
  # Use include_blank string as blank option text (Phlexi default uses placeholder)
24
132
  def blank_option_text
25
133
  @include_blank.is_a?(String) ? @include_blank : super
26
134
  end
135
+
136
+ private
137
+
138
+ def typeahead_target_class
139
+ @association_class
140
+ end
141
+
142
+ def typeahead_kind_and_name(_typeahead_option)
143
+ detect_typeahead_kind_and_name
144
+ end
145
+
146
+ # Plutonium::UI::Form::Query roots its form with `as: :q`, so
147
+ # any field whose ancestry includes a node keyed :q is a filter
148
+ # input. The filter name is the immediate child of that root.
149
+ # Form inputs (new/edit) fall through to :input + the field key.
150
+ def detect_typeahead_kind_and_name
151
+ lineage = field.dom.lineage
152
+ q_index = lineage.find_index { |node| node.key == :q }
153
+ if q_index && (filter_node = lineage[q_index + 1])
154
+ [:filter, filter_node.key]
155
+ else
156
+ [:input, field.key]
157
+ end
158
+ end
27
159
  end
28
160
  end
29
161
  end