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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +85 -102
- data/.claude/skills/plutonium-app/SKILL.md +572 -0
- data/.claude/skills/plutonium-auth/SKILL.md +163 -300
- data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
- data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
- data/.claude/skills/plutonium-testing/SKILL.md +6 -5
- data/.claude/skills/plutonium-ui/SKILL.md +900 -0
- data/CHANGELOG.md +27 -2
- data/Rakefile +2 -1
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1009 -1214
- data/app/assets/plutonium.js.map +3 -3
- data/app/assets/plutonium.min.js +52 -51
- data/app/assets/plutonium.min.js.map +3 -3
- data/docs/.vitepress/config.ts +37 -27
- data/docs/getting-started/index.md +22 -29
- data/docs/getting-started/installation.md +37 -80
- data/docs/getting-started/tutorial/index.md +4 -5
- data/docs/guides/adding-resources.md +66 -377
- data/docs/guides/authentication.md +94 -463
- data/docs/guides/authorization.md +124 -370
- data/docs/guides/creating-packages.md +94 -296
- data/docs/guides/custom-actions.md +121 -441
- data/docs/guides/index.md +22 -42
- data/docs/guides/multi-tenancy.md +116 -187
- data/docs/guides/nested-resources.md +103 -431
- data/docs/guides/search-filtering.md +123 -240
- data/docs/guides/testing.md +5 -4
- data/docs/guides/theming.md +157 -407
- data/docs/guides/troubleshooting.md +5 -3
- data/docs/guides/user-invites.md +106 -425
- data/docs/guides/user-profile.md +76 -243
- data/docs/index.md +1 -1
- data/docs/reference/app/generators.md +517 -0
- data/docs/reference/app/index.md +158 -0
- data/docs/reference/app/packages.md +146 -0
- data/docs/reference/app/portals.md +377 -0
- data/docs/reference/auth/accounts.md +230 -0
- data/docs/reference/auth/index.md +88 -0
- data/docs/reference/auth/profile.md +185 -0
- data/docs/reference/behavior/controllers.md +395 -0
- data/docs/reference/behavior/index.md +22 -0
- data/docs/reference/behavior/interactions.md +341 -0
- data/docs/reference/behavior/policies.md +417 -0
- data/docs/reference/index.md +56 -49
- data/docs/reference/resource/actions.md +423 -0
- data/docs/reference/resource/definition.md +508 -0
- data/docs/reference/resource/index.md +50 -0
- data/docs/reference/resource/model.md +348 -0
- data/docs/reference/resource/query.md +305 -0
- data/docs/reference/tenancy/entity-scoping.md +361 -0
- data/docs/reference/tenancy/index.md +36 -0
- data/docs/reference/tenancy/invites.md +393 -0
- data/docs/reference/tenancy/nested-resources.md +267 -0
- data/docs/reference/testing/index.md +287 -0
- data/docs/reference/ui/assets.md +400 -0
- data/docs/reference/ui/components.md +165 -0
- data/docs/reference/ui/displays.md +104 -0
- data/docs/reference/ui/forms.md +284 -0
- data/docs/reference/ui/index.md +30 -0
- data/docs/reference/ui/layouts.md +106 -0
- data/docs/reference/ui/pages.md +189 -0
- data/docs/reference/ui/tables.md +117 -0
- data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
- data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
- data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +0 -20
- data/lib/generators/pu/invites/install_generator.rb +1 -0
- data/lib/plutonium/definition/base.rb +1 -1
- data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
- data/lib/plutonium/helpers/turbo_helper.rb +11 -0
- data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
- data/lib/plutonium/resource/controller.rb +1 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
- data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
- data/lib/plutonium/resource/policy.rb +7 -0
- data/lib/plutonium/routing/mapper_extensions.rb +15 -0
- data/lib/plutonium/ui/component/methods.rb +4 -0
- data/lib/plutonium/ui/form/base.rb +6 -2
- data/lib/plutonium/ui/form/components/json.rb +58 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
- data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
- data/lib/plutonium/ui/form/resource.rb +0 -4
- data/lib/plutonium/ui/grid/resource.rb +1 -1
- data/lib/plutonium/ui/layout/base.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +0 -7
- data/lib/plutonium/ui/page/index.rb +4 -4
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +8 -0
- data/lib/tasks/release.rake +15 -1
- data/package.json +10 -10
- data/src/css/slim_select.css +4 -0
- data/src/js/controllers/slim_select_controller.js +61 -0
- data/src/js/turbo/turbo_actions.js +33 -0
- data/yarn.lock +553 -543
- metadata +44 -33
- data/.claude/skills/plutonium-assets/SKILL.md +0 -512
- data/.claude/skills/plutonium-controller/SKILL.md +0 -396
- data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
- data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
- data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
- data/.claude/skills/plutonium-forms/SKILL.md +0 -465
- data/.claude/skills/plutonium-installation/SKILL.md +0 -331
- data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
- data/.claude/skills/plutonium-invites/SKILL.md +0 -408
- data/.claude/skills/plutonium-model/SKILL.md +0 -440
- data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
- data/.claude/skills/plutonium-package/SKILL.md +0 -198
- data/.claude/skills/plutonium-policy/SKILL.md +0 -456
- data/.claude/skills/plutonium-portal/SKILL.md +0 -410
- data/.claude/skills/plutonium-views/SKILL.md +0 -651
- data/docs/reference/assets/index.md +0 -496
- data/docs/reference/controller/index.md +0 -412
- data/docs/reference/definition/actions.md +0 -462
- data/docs/reference/definition/fields.md +0 -383
- data/docs/reference/definition/index.md +0 -326
- data/docs/reference/definition/query.md +0 -351
- data/docs/reference/generators/index.md +0 -648
- data/docs/reference/interaction/index.md +0 -449
- data/docs/reference/model/features.md +0 -248
- data/docs/reference/model/index.md +0 -218
- data/docs/reference/policy/index.md +0 -456
- data/docs/reference/portal/index.md +0 -379
- data/docs/reference/views/forms.md +0 -411
- 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, :
|
|
111
|
-
alias_method :jsonb_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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
27
|
-
href:
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 =
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
@@ -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.
|
|
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)
|
|
@@ -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 `
|
|
50
|
-
# `
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
data/lib/plutonium/version.rb
CHANGED
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|
|
data/lib/tasks/release.rake
CHANGED
|
@@ -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
|
-
|
|
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"
|