plutonium 0.50.0 → 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 (132) 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 +27 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1009 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +37 -27
  18. data/docs/getting-started/index.md +22 -29
  19. data/docs/getting-started/installation.md +37 -80
  20. data/docs/getting-started/tutorial/index.md +4 -5
  21. data/docs/guides/adding-resources.md +66 -377
  22. data/docs/guides/authentication.md +94 -463
  23. data/docs/guides/authorization.md +124 -370
  24. data/docs/guides/creating-packages.md +94 -296
  25. data/docs/guides/custom-actions.md +121 -441
  26. data/docs/guides/index.md +22 -42
  27. data/docs/guides/multi-tenancy.md +116 -187
  28. data/docs/guides/nested-resources.md +103 -431
  29. data/docs/guides/search-filtering.md +123 -240
  30. data/docs/guides/testing.md +5 -4
  31. data/docs/guides/theming.md +157 -407
  32. data/docs/guides/troubleshooting.md +5 -3
  33. data/docs/guides/user-invites.md +106 -425
  34. data/docs/guides/user-profile.md +76 -243
  35. data/docs/index.md +1 -1
  36. data/docs/reference/app/generators.md +517 -0
  37. data/docs/reference/app/index.md +158 -0
  38. data/docs/reference/app/packages.md +146 -0
  39. data/docs/reference/app/portals.md +377 -0
  40. data/docs/reference/auth/accounts.md +230 -0
  41. data/docs/reference/auth/index.md +88 -0
  42. data/docs/reference/auth/profile.md +185 -0
  43. data/docs/reference/behavior/controllers.md +395 -0
  44. data/docs/reference/behavior/index.md +22 -0
  45. data/docs/reference/behavior/interactions.md +341 -0
  46. data/docs/reference/behavior/policies.md +417 -0
  47. data/docs/reference/index.md +56 -49
  48. data/docs/reference/resource/actions.md +423 -0
  49. data/docs/reference/resource/definition.md +508 -0
  50. data/docs/reference/resource/index.md +50 -0
  51. data/docs/reference/resource/model.md +348 -0
  52. data/docs/reference/resource/query.md +305 -0
  53. data/docs/reference/tenancy/entity-scoping.md +361 -0
  54. data/docs/reference/tenancy/index.md +36 -0
  55. data/docs/reference/tenancy/invites.md +393 -0
  56. data/docs/reference/tenancy/nested-resources.md +267 -0
  57. data/docs/reference/testing/index.md +287 -0
  58. data/docs/reference/ui/assets.md +400 -0
  59. data/docs/reference/ui/components.md +165 -0
  60. data/docs/reference/ui/displays.md +104 -0
  61. data/docs/reference/ui/forms.md +284 -0
  62. data/docs/reference/ui/index.md +30 -0
  63. data/docs/reference/ui/layouts.md +106 -0
  64. data/docs/reference/ui/pages.md +189 -0
  65. data/docs/reference/ui/tables.md +117 -0
  66. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  67. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  68. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  69. data/gemfiles/rails_7.gemfile.lock +1 -1
  70. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  72. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  73. data/lib/generators/pu/invites/install_generator.rb +1 -0
  74. data/lib/plutonium/definition/base.rb +1 -1
  75. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  76. data/lib/plutonium/helpers/turbo_helper.rb +11 -0
  77. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  78. data/lib/plutonium/resource/controller.rb +1 -0
  79. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  80. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  81. data/lib/plutonium/resource/policy.rb +7 -0
  82. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  83. data/lib/plutonium/ui/component/methods.rb +4 -0
  84. data/lib/plutonium/ui/form/base.rb +6 -2
  85. data/lib/plutonium/ui/form/components/json.rb +58 -0
  86. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  87. data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
  88. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  89. data/lib/plutonium/ui/form/resource.rb +0 -4
  90. data/lib/plutonium/ui/grid/resource.rb +1 -1
  91. data/lib/plutonium/ui/layout/base.rb +1 -0
  92. data/lib/plutonium/ui/page/base.rb +0 -7
  93. data/lib/plutonium/ui/page/index.rb +4 -4
  94. data/lib/plutonium/ui/table/resource.rb +1 -1
  95. data/lib/plutonium/version.rb +1 -1
  96. data/lib/plutonium.rb +8 -0
  97. data/lib/tasks/release.rake +15 -1
  98. data/package.json +10 -10
  99. data/src/css/slim_select.css +4 -0
  100. data/src/js/controllers/slim_select_controller.js +61 -0
  101. data/src/js/turbo/turbo_actions.js +33 -0
  102. data/yarn.lock +553 -543
  103. metadata +44 -33
  104. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  105. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  106. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  107. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  108. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  109. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  110. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  111. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  112. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  113. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  114. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  115. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  116. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  117. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  118. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  119. data/docs/reference/assets/index.md +0 -496
  120. data/docs/reference/controller/index.md +0 -412
  121. data/docs/reference/definition/actions.md +0 -462
  122. data/docs/reference/definition/fields.md +0 -383
  123. data/docs/reference/definition/index.md +0 -326
  124. data/docs/reference/definition/query.md +0 -351
  125. data/docs/reference/generators/index.md +0 -648
  126. data/docs/reference/interaction/index.md +0 -449
  127. data/docs/reference/model/features.md +0 -248
  128. data/docs/reference/model/index.md +0 -218
  129. data/docs/reference/policy/index.md +0 -456
  130. data/docs/reference/portal/index.md +0 -379
  131. data/docs/reference/views/forms.md +0 -411
  132. data/docs/reference/views/index.md +0 -544
@@ -41,6 +41,7 @@ module Plutonium
41
41
  concern :interactive_resource_actions do
42
42
  define_member_interactive_actions
43
43
  define_collection_interactive_actions
44
+ define_collection_typeahead_actions
44
45
  end
45
46
  end
46
47
 
@@ -161,6 +162,20 @@ module Plutonium
161
162
  as: :commit_interactive_resource_action
162
163
  end
163
164
  end
165
+
166
+ # Defines collection-level typeahead actions for resource form inputs
167
+ # and index filter inputs. Auto-mounted alongside record_actions and
168
+ # bulk_actions on every Plutonium resource.
169
+ #
170
+ # @return [void]
171
+ def define_collection_typeahead_actions
172
+ collection do
173
+ get "typeahead/input/:name", action: :typeahead_input,
174
+ as: :typeahead_input
175
+ get "typeahead/filter/:name", action: :typeahead_filter,
176
+ as: :typeahead_filter
177
+ end
178
+ end
164
179
  end
165
180
  end
166
181
  end
@@ -40,10 +40,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,
@@ -66,6 +66,10 @@ module Plutonium
66
66
  create_component(Components::KeyValueStore, :key_value_store, **, &)
67
67
  end
68
68
 
69
+ def json_input_tag(**, &)
70
+ create_component(Components::Json, :json, **, &)
71
+ end
72
+
69
73
  def resource_select_tag(**attributes, &)
70
74
  attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select")
71
75
  # class!: "" clears the underlying <select>'s themed classes
@@ -107,8 +111,8 @@ module Plutonium
107
111
  alias_method :date_tag, :flatpickr_tag
108
112
  alias_method :time_tag, :flatpickr_tag
109
113
  alias_method :rich_text_tag, :markdown_tag
110
- alias_method :json_tag, :textarea_tag
111
- alias_method :jsonb_tag, :textarea_tag
114
+ alias_method :json_tag, :json_input_tag
115
+ alias_method :jsonb_tag, :json_input_tag
112
116
  alias_method :hstore_tag, :key_value_store_tag
113
117
  alias_method :key_value_tag, :key_value_store_tag
114
118
  alias_method :association_tag, :secure_association_tag
@@ -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
@@ -7,6 +7,7 @@ module Plutonium
7
7
  # Select for choosing a resource record
8
8
  class ResourceSelect < Phlexi::Form::Components::Select
9
9
  include Plutonium::UI::Component::Methods
10
+ include Plutonium::UI::Form::Concerns::TypeaheadAttributes
10
11
 
11
12
  # Cap on the number of records the dropdown materialises. Keeps
12
13
  # very large association tables from rendering thousands of
@@ -23,18 +24,23 @@ module Plutonium
23
24
  elsif @association_class.nil?
24
25
  []
25
26
  else
26
- relation = @association_class.all
27
- relation = relation.limit(@choice_limit) if relation.respond_to?(:limit) && @choice_limit
28
- if @skip_authorization
29
- relation
30
- else
31
- authorized_resource_scope(@association_class, relation: relation)
32
- end
27
+ authorized_relation(limit: @choice_limit)
33
28
  end
34
29
  build_choice_mapper(collection)
35
30
  end
36
31
  end
37
32
 
33
+ # Builds the authorized relation for the association class, optionally
34
+ # capped at `limit`. Shared by `choices` (with the limit) and
35
+ # `normalize_simple_input` (without — so typeahead picks beyond the
36
+ # rendered subset still validate).
37
+ def authorized_relation(limit: nil)
38
+ relation = @association_class.all
39
+ relation = relation.limit(limit) if limit && relation.respond_to?(:limit)
40
+ return relation if @skip_authorization
41
+ authorized_resource_scope(@association_class, relation: relation)
42
+ end
43
+
38
44
  def build_attributes
39
45
  # Defaults must land BEFORE super — AcceptsChoices.build_attributes
40
46
  # consumes :value_method / :label_method off `attributes` into
@@ -48,6 +54,17 @@ module Plutonium
48
54
  @skip_authorization = attributes.delete(:skip_authorization)
49
55
  @choice_limit = attributes.fetch(:choice_limit) { DEFAULT_CHOICE_LIMIT }
50
56
  attributes.delete(:choice_limit)
57
+ # Stash the typeahead option; the URL helper needs view_context
58
+ # which only exists once we're rendering.
59
+ @typeahead_option = attributes.delete(:typeahead)
60
+ end
61
+
62
+ # Phlex hook fires right before view_template runs and view_context
63
+ # is available, so this is where we can resolve the typeahead URL
64
+ # and inject the data attr.
65
+ def before_template
66
+ super
67
+ configure_typeahead_attributes!(@typeahead_option)
51
68
  end
52
69
 
53
70
  # SGIDs include a timestamp + signature, so the SGID in the URL
@@ -69,9 +86,22 @@ module Plutonium
69
86
  # string-equals a freshly generated option SGID for the same
70
87
  # record, so the value gets silently dropped — no WHERE clause
71
88
  # is built and the filter behaves as if it weren't applied.
72
- # Match by decoded model id so the input survives.
89
+ #
90
+ # For SGID values backed by an association class, validate against
91
+ # the unbounded authorized relation rather than the rendered
92
+ # `choices` (which is capped at `choice_limit` and may not include
93
+ # records reachable via typeahead). For raw values / explicit
94
+ # `@raw_choices`, fall back to record-equality against the rendered
95
+ # options.
73
96
  def normalize_simple_input(input_value)
74
97
  return nil if input_value.blank?
98
+
99
+ sgid = SignedGlobalID.parse(input_value)
100
+ if sgid && @association_class && !@raw_choices
101
+ return nil unless sgid.model_class <= @association_class
102
+ return authorized_relation.exists?(id: sgid.model_id) ? input_value : nil
103
+ end
104
+
75
105
  choices.values.find { |opt| same_record?(input_value, opt) } && input_value
76
106
  end
77
107
 
@@ -102,6 +132,30 @@ module Plutonium
102
132
  def blank_option_text
103
133
  @include_blank.is_a?(String) ? @include_blank : super
104
134
  end
135
+
136
+ private
137
+
138
+ def typeahead_target_class
139
+ @association_class
140
+ end
141
+
142
+ def typeahead_kind_and_name(_typeahead_option)
143
+ detect_typeahead_kind_and_name
144
+ end
145
+
146
+ # Plutonium::UI::Form::Query roots its form with `as: :q`, so
147
+ # any field whose ancestry includes a node keyed :q is a filter
148
+ # input. The filter name is the immediate child of that root.
149
+ # Form inputs (new/edit) fall through to :input + the field key.
150
+ def detect_typeahead_kind_and_name
151
+ lineage = field.dom.lineage
152
+ q_index = lineage.find_index { |node| node.key == :q }
153
+ if q_index && (filter_node = lineage[q_index + 1])
154
+ [:filter, filter_node.key]
155
+ else
156
+ [:input, field.key]
157
+ end
158
+ end
105
159
  end
106
160
  end
107
161
  end
@@ -6,6 +6,7 @@ module Plutonium
6
6
  module Components
7
7
  class SecureAssociation < Phlexi::Form::Components::AssociationBase
8
8
  include Plutonium::UI::Component::Methods
9
+ include Plutonium::UI::Form::Concerns::TypeaheadAttributes
9
10
 
10
11
  DEFAULT_CHOICE_LIMIT = Plutonium::UI::Form::Components::ResourceSelect::DEFAULT_CHOICE_LIMIT
11
12
 
@@ -21,46 +22,95 @@ module Plutonium
21
22
  delegate :association_reflection, to: :field
22
23
 
23
24
  def render_add_button
24
- return if @add_action == false || add_url.nil?
25
+ return if @add_action == false
26
+
27
+ 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
25
39
 
26
- a(
27
- href: add_url,
40
+ attrs = {
41
+ href: url,
28
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"
29
- ) do
43
+ }
44
+ attrs[:data] = {turbo_frame: turbo_frame} if turbo_frame
45
+
46
+ a(**attrs) do
30
47
  render Phlex::TablerIcons::Plus.new(class: "w-4 h-4")
31
48
  end
32
49
  end
33
50
 
34
- def add_url
35
- @add_url ||= begin
36
- 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
37
66
 
38
- url = @add_action || (registered_resources.include?(association_reflection.klass) && resource_url_for(association_reflection.klass, action: :new, parent: nil))
39
- 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))
40
71
 
41
- uri = URI(url)
42
- uri.query = URI.encode_www_form({return_to: request.original_url})
43
- uri.to_s
44
- 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
45
82
  end
46
83
 
47
84
  def choices
48
85
  @choices ||= begin
49
- collection = if @raw_choices
50
- @raw_choices
51
- elsif @skip_authorization
52
- choices_from_association(association_reflection.klass)
53
- else
54
- authorized_resource_scope(association_reflection.klass, relation: choices_from_association(association_reflection.klass))
55
- end
86
+ collection = @raw_choices || authorized_relation
56
87
  collection = collection.limit(@choice_limit) if @choice_limit && collection.respond_to?(:limit)
57
88
  build_choice_mapper(collection)
58
89
  end
59
90
  end
60
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
+
61
103
  def build_attributes
62
104
  build_association_attributes
63
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)
64
114
  end
65
115
 
66
116
  def build_association_attributes
@@ -79,6 +129,20 @@ module Plutonium
79
129
  end
80
130
  end
81
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
+
82
146
  def build_singluar_association_attributes
83
147
  attributes.fetch(:input_param) { attributes[:input_param] = :"#{association_reflection.name}_sgid" }
84
148
  end
@@ -88,9 +152,21 @@ module Plutonium
88
152
  attributes[:multiple] = true
89
153
  end
90
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.
91
159
  def normalize_simple_input(input_value)
92
- @signed_global_ids ||= choices.values.map { |choice| SignedGlobalID.parse(choice) }
93
- ([SignedGlobalID.parse(input_value.presence)].compact & @signed_global_ids)[0]
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
94
170
  end
95
171
 
96
172
  def selected?(option)
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Concerns
7
+ # Shared typeahead wiring for association/resource select components.
8
+ #
9
+ # Hosts must implement two hooks:
10
+ #
11
+ # typeahead_target_class -> the associated model class, or nil
12
+ # (returns nil for polymorphic/unknown)
13
+ # typeahead_kind_and_name(opt) -> [:input | :filter, Symbol], used when
14
+ # the consumer didn't pass an explicit
15
+ # `typeahead: {kind:, name:}` hash
16
+ #
17
+ # The concern owns:
18
+ # - `configure_typeahead_attributes!` — call from `before_template`
19
+ # - `typeahead_searchable?` — registry + fallback-column check
20
+ # - `typeahead_url_for` — engine route helper lookup
21
+ module TypeaheadAttributes
22
+ extend ActiveSupport::Concern
23
+
24
+ private
25
+
26
+ # Adds the typeahead URL data attr so the slim-select Stimulus
27
+ # controller delegates to the backend via events.search.
28
+ # Default-on (opt out with `typeahead: false`). Pass a Hash to
29
+ # override kind/name (e.g. `typeahead: {kind: :filter, name: :status}`).
30
+ #
31
+ # Auto opt-out: if the associated resource has neither a `search`
32
+ # block nor a fallback search column on the model, fall back to
33
+ # slim-select's eager list + client-side filter — the backend
34
+ # would just return unfiltered records.
35
+ def configure_typeahead_attributes!(typeahead_option)
36
+ return if typeahead_option == false
37
+ return unless typeahead_searchable?
38
+ url = typeahead_url_for(typeahead_option)
39
+ return unless url
40
+ attributes[:data_slim_select_typeahead_url_value] = url
41
+ end
42
+
43
+ def typeahead_searchable?
44
+ klass = typeahead_target_class
45
+ return false unless klass
46
+
47
+ # Go through `resource_definition` so portal/package namespacing
48
+ # is honored — a portal can ship its own definition with a
49
+ # different `search` block than the base.
50
+ return true if resource_definition(klass).class._search_definition.present?
51
+
52
+ Plutonium::Resource::Controllers::Typeahead
53
+ .searchable_column_for(klass, label_method: @label_method).present?
54
+ rescue NameError
55
+ false
56
+ end
57
+
58
+ def typeahead_url_for(typeahead_option)
59
+ kind, name = if typeahead_option.is_a?(Hash)
60
+ [typeahead_option[:kind] || :input, typeahead_option[:name]]
61
+ else
62
+ typeahead_kind_and_name(typeahead_option)
63
+ end
64
+ return nil unless name
65
+
66
+ route_key = resource_class.model_name.route_key
67
+ helper = (kind == :filter) ? :"typeahead_filter_#{route_key}_path" : :"typeahead_input_#{route_key}_path"
68
+
69
+ # Engine route helpers are the source of truth for routes
70
+ # mounted under a Plutonium portal — phlex-rails' `helpers`
71
+ # proxy is deprecated and not the right entry point here.
72
+ # Helper may be absent if a consumer removed the typeahead
73
+ # route from the resource — fall back to no URL, slim-select
74
+ # uses its eager list.
75
+ url_helpers = current_engine.routes.url_helpers
76
+ return nil unless url_helpers.respond_to?(helper)
77
+ url_helpers.public_send(helper, name: name)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -70,10 +70,6 @@ module Plutonium
70
70
  end
71
71
  end
72
72
 
73
- def in_modal?
74
- current_turbo_frame == Plutonium::REMOTE_MODAL_FRAME
75
- end
76
-
77
73
  def show_submit_and_continue?
78
74
  return false unless object.respond_to?(:new_record?)
79
75
 
@@ -44,7 +44,7 @@ module Plutonium
44
44
  query: current_query_object,
45
45
  search_url: request.path,
46
46
  search_value: params.dig(:q, :search) || params[:search],
47
- views: resource_definition.defined_views,
47
+ views: resource_definition.defined_index_views,
48
48
  current_view: :grid,
49
49
  view_cookie_name: Plutonium::UI::Page::Index.view_cookie_name(resource_class),
50
50
  view_cookie_path: Plutonium::UI::Page::Index.view_cookie_path(request)
@@ -105,6 +105,7 @@ module Plutonium
105
105
 
106
106
  def render_after_main
107
107
  turbo_frame_tag(Plutonium::REMOTE_MODAL_FRAME)
108
+ turbo_frame_tag(Plutonium::REMOTE_MODAL_SECONDARY_FRAME)
108
109
  end
109
110
 
110
111
  def render_content(&)
@@ -90,13 +90,6 @@ module Plutonium
90
90
  # Returns false by default; pages opt-in by overriding.
91
91
  def aside_present? = false
92
92
 
93
- # True when the page is rendered inside any turbo frame.
94
- def in_frame? = current_turbo_frame.present?
95
-
96
- # True when the page is rendered inside the remote_modal turbo frame.
97
- # Used by form pages to suppress the sticky footer (modal owns its own footer).
98
- def in_modal? = current_turbo_frame == Plutonium::REMOTE_MODAL_FRAME
99
-
100
93
  # Customization hooks
101
94
  def render_before_header
102
95
  end
@@ -46,11 +46,11 @@ module Plutonium
46
46
  # Resolution order:
47
47
  # 1. `?view=` URL param (so a shared link can pin a view)
48
48
  # 2. The view-preference cookie (sticky per-resource selection)
49
- # 3. The resource's `default_view` (which itself defaults to
50
- # `views.first`)
49
+ # 3. The resource's `default_index_view` (which itself defaults to
50
+ # `index_views.first`)
51
51
  def selected_view
52
52
  definition = current_definition
53
- enabled = definition.defined_views
53
+ enabled = definition.defined_index_views
54
54
 
55
55
  requested = params[:view]&.to_sym
56
56
  return requested if requested && enabled.include?(requested)
@@ -58,7 +58,7 @@ module Plutonium
58
58
  stored = helpers.cookies[self.class.view_cookie_name(resource_class)]&.to_sym
59
59
  return stored if stored && enabled.include?(stored)
60
60
 
61
- definition.default_view
61
+ definition.default_index_view
62
62
  end
63
63
 
64
64
  def page_type = :index_page
@@ -44,7 +44,7 @@ module Plutonium
44
44
  query: current_query_object,
45
45
  search_url: current_search_url,
46
46
  search_value: params.dig(:q, :search) || params[:search],
47
- views: resource_definition.defined_views,
47
+ views: resource_definition.defined_index_views,
48
48
  current_view: :table,
49
49
  view_cookie_name: Plutonium::UI::Page::Index.view_cookie_name(resource_class),
50
50
  view_cookie_path: Plutonium::UI::Page::Index.view_cookie_path(request)
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.50.0"
2
+ VERSION = "0.51.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/lib/plutonium.rb CHANGED
@@ -28,6 +28,14 @@ module Plutonium
28
28
  # frame name lives in one place.
29
29
  REMOTE_MODAL_FRAME = "remote_modal"
30
30
 
31
+ # Secondary modal frame, used to stack a modal on top of the primary one
32
+ # (e.g. clicking the inline "+" next to an association field while the
33
+ # parent form is itself rendered in the primary modal). The layout
34
+ # renders a second frame, and `in_modal?` recognises both.
35
+ REMOTE_MODAL_SECONDARY_FRAME = "remote_modal_secondary"
36
+
37
+ MODAL_FRAMES = [REMOTE_MODAL_FRAME, REMOTE_MODAL_SECONDARY_FRAME].freeze
38
+
31
39
  # Set up Zeitwerk loader for the Plutonium gem
32
40
  # @return [Zeitwerk::Loader] configured Zeitwerk loader instance
33
41
  Loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false).tap do |loader|
@@ -102,7 +102,11 @@ namespace :release do
102
102
  desc "Build front-end assets"
103
103
  task :build_frontend do
104
104
  puts "Building front-end assets..."
105
- system("yarn build") || abort("Front-end build failed")
105
+ # in: File::NULL — yarn 4 puts the terminal in raw mode for its
106
+ # progress UI and doesn't always restore it on exit. Without this,
107
+ # subsequent `$stdin.gets` prompts read one keystroke at a time and
108
+ # never see a newline, so Enter never terminates the line.
109
+ system("yarn build", in: File::NULL) || abort("Front-end build failed")
106
110
  puts "✓ Built front-end assets"
107
111
  end
108
112
 
@@ -160,6 +164,14 @@ namespace :release do
160
164
  exit 1
161
165
  end
162
166
 
167
+ # Snapshot the terminal mode up front. yarn 4 and git-cliff both put
168
+ # the TTY in raw mode for progress UIs and don't always restore it,
169
+ # which breaks every subsequent `$stdin.gets` (Enter arrives as a
170
+ # bare \r and gets() never returns). Restore the snapshot before each
171
+ # prompt so the user can actually answer.
172
+ tty_state = `stty -g 2>/dev/null`.strip
173
+ restore_tty = -> { system("stty #{tty_state} 2>/dev/null") if tty_state != "" }
174
+
163
175
  puts "Starting release workflow for v#{version}..."
164
176
 
165
177
  # Check npm authentication early, login if needed
@@ -179,6 +191,7 @@ namespace :release do
179
191
  current_branch = `git branch --show-current`.strip
180
192
  unless current_branch == "main" || current_branch == "master"
181
193
  puts "Warning: You're not on main/master branch (current: #{current_branch})"
194
+ restore_tty.call
182
195
  print "Continue anyway? [y/N] "
183
196
  exit 1 unless $stdin.gets.strip.downcase == "y"
184
197
  end
@@ -187,6 +200,7 @@ namespace :release do
187
200
  Rake::Task["release:prepare"].invoke(version)
188
201
 
189
202
  # Confirm before proceeding
203
+ restore_tty.call
190
204
  puts "\nReady to commit, tag, and publish?"
191
205
  print "Continue? [y/N] "
192
206
  exit 0 unless $stdin.gets.strip.downcase == "y"